首页 > 代码库 > MVVM 实战之计算器

MVVM 实战之计算器

MVVM 实战之计算器

android
DataBinding
MVVM
calculator

 

    • Model
    • View
      • 布局文件
      • Fragment
    • ViewModel
    • 结束语

 

前些日子,一直在学习基于 RxAndroid + Retrofit + DataBinding 技术组合的 MVVM 解决方案。初识这些知识,深深被它们的巧妙构思和方便快捷所吸引,心中颇为激动。但是,“纸上得来终觉浅,绝知此事要躬行”,学习完以后心里还是没有谱,于是,决定自己动手做一个基于这些技术和框架的小应用。

既然是对新技术学习和掌握的练习,因此,摊子不宜铺的太大。经过思量,最终决定使用 DataBinding 技术构建一个小的 MVVM 应用。MVVM 就是 Model-View-ViewModel 的缩写,与 MVC 模式相比,把其中的 Control 更换为 ViewModel 了。MVVM 的特点:ModelView 之间完全没有直接的联系,但是,通过 ViewModelModel 的变化可以反映在 View 上,对 View 操作呢,又可以影响到 Model

平时在编写 Android 应用时,大家都在深受 findViewById 的折磨。DataBinding 还有个好处,就是完全不需要使用 findViewById 来获取控件(当然,需要在布局文件中给控件设置 id 属性)。有了 DataBinding 的支持,在数据变化后,也不需使用代码来改变控件的显示了。这样,我们的代码就清爽多了。

Model


MVVM 中,Model 的变化可以直接反映到 View 上,而不需要通过代码进行设置。这样,就不能用普通的 Java 类型的变量了。Android 专门为这种变量定义了新的变量类型:ObservableXXX

注意:ObservableXXX 是在 android.databinding 包下

变量定义如下:

/** 被操作数 */public ObservableField<String> firstNum = new ObservableField<>("0");/** 上一次结果 */public ObservableField<String> secondNum = new ObservableField<>("");/** 当前结果 */public ObservableField<String> resNum = new ObservableField<>("");

  

变量的定义位置应该在 ViewModel 中,后方会有完整代码。

View

布局文件


DataBinding 的布局特点是把正常布局包裹在 layout 节点中,layout 布局中的第一个子直接子元素必须是 data 节点。因为,计算器布局的特点非常符合网格布局的特点,因此,我们选择 GridLayout 控件作为 layout 布局中的第二个直接子元素。布局内容如下:

技术分享
  1 <?xml version="1.0" encoding="utf-8"?>  2 <layout xmlns:android="http://schemas.android.com/apk/res/android"  3         xmlns:tools="http://schemas.android.com/tools"  4         xmlns:app="http://schemas.android.com/apk/res-auto">  5     <data>  6         <variable  7             name="cal"  8             type="com.ch.wchhuangya.android.pandora.vm.CalculatorVM"/>  9     </data> 10  11     <LinearLayout 12                   android:layout_width="match_parent" 13                   android:layout_height="match_parent" 14                   android:orientation="vertical"> 15  16         <LinearLayout 17             android:layout_width="match_parent" 18             android:layout_height="0dp" 19             android:layout_marginBottom="10dp" 20             android:layout_weight="2" 21             android:gravity="bottom" 22             android:orientation="vertical" 23             > 24  25             <TextView 26                 android:id="@+id/cal_top_num" 27                 android:layout_width="match_parent" 28                 android:layout_height="wrap_content" 29                 android:gravity="right" 30                 android:maxLines="1" 31                 android:paddingRight="10dp" 32                 android:text="@{cal.secondNum}" 33                 android:textColor="#555" 34                 android:textSize="35sp" 35                 tools:text="16" 36                 /> 37  38             <TextView 39                 android:id="@+id/cal_bottom_num" 40                 android:layout_width="match_parent" 41                 android:layout_height="wrap_content" 42                 android:gravity="right" 43                 android:maxLines="1" 44                 android:paddingRight="10dp" 45                 android:text="@{cal.firstNum}" 46                 android:textColor="#222" 47                 android:textSize="45sp" 48                 tools:text="+ 3234234" 49                 /> 50  51             <TextView 52                 android:id="@+id/cal_res" 53                 android:layout_width="match_parent" 54                 android:layout_height="wrap_content" 55                 android:gravity="right" 56                 android:maxLines="1" 57                 android:paddingRight="10dp" 58                 android:text="@{cal.resNum}" 59                 android:textColor="#888" 60                 android:textSize="30sp" 61                 tools:text="= 3234250" 62                 /> 63  64         </LinearLayout> 65  66         <android.support.v7.widget.GridLayout 67             android:layout_width="match_parent" 68             android:layout_height="0dp" 69             android:layout_weight="3" 70             app:columnCount="4" 71             app:orientation="horizontal" 72             app:rowCount="5" 73             > 74  75             <Button 76                 android:id="@+id/cal_clear" 77                 android:layout_marginLeft="5dp" 78                 android:layout_marginRight="5dp" 79                 app:layout_rowWeight="1" 80                 android:text="clear" 81                 android:onClick="@{() -> cal.clear()}" 82                 /> 83  84             <Button 85                 android:id="@+id/cal_del" 86                 android:layout_marginRight="5dp" 87                 app:layout_rowWeight="1" 88                 android:text="del" 89                 android:onClick="@{() -> cal.del()}" 90                 /> 91  92             <Button 93                 android:id="@+id/cal_divide" 94                 android:layout_marginRight="5dp" 95                 app:layout_rowWeight="1" 96                 android:text="÷" 97                 android:onClick="@{cal::operatorClick}" 98                 /> 99 100             <Button101                 android:id="@+id/cal_multiply"102                 app:layout_rowWeight="1"103                 android:text="×"104                 android:onClick="@{cal::operatorClick}"105                 />106 107             <Button108                 android:id="@+id/cal_7"109                 android:layout_marginLeft="5dp"110                 app:layout_rowWeight="1"111                 android:text="7"112                 android:onClick="@{cal::numClick}"113                 />114 115             <Button116                 android:id="@+id/cal_8"117                 app:layout_rowWeight="1"118                 android:text="8"119                 android:onClick="@{cal::numClick}"120                 />121 122             <Button123                 android:id="@+id/cal_9"124                 app:layout_rowWeight="1"125                 android:text="9"126                 android:onClick="@{cal::numClick}"127                 />128 129             <Button130                 android:id="@+id/cal_minus"131                 app:layout_rowWeight="1"132                 android:text="-"133                 android:onClick="@{cal::operatorClick}"134                 />135 136             <Button137                 android:id="@+id/cal_4"138                 android:layout_marginLeft="5dp"139                 app:layout_rowWeight="1"140                 android:text="4"141                 android:onClick="@{cal::numClick}"142                 />143 144             <Button145                 android:id="@+id/cal_5"146                 app:layout_rowWeight="1"147                 android:text="5"148                 android:onClick="@{cal::numClick}"149                 />150 151             <Button152                 android:id="@+id/cal_6"153                 app:layout_rowWeight="1"154                 android:text="6"155                 android:onClick="@{cal::numClick}"156                 />157 158             <Button159                 android:id="@+id/cal_add"160                 app:layout_rowWeight="1"161                 android:text="+"162                 android:onClick="@{cal::operatorClick}"163                 />164 165             <Button166                 android:id="@+id/cal_1"167                 android:layout_marginLeft="5dp"168                 app:layout_rowWeight="1"169                 android:text="1"170                 android:onClick="@{cal::numClick}"171                 />172 173             <Button174                 android:id="@+id/cal_2"175                 app:layout_rowWeight="1"176                 android:text="2"177                 android:onClick="@{cal::numClick}"178                 />179 180             <Button181                 android:id="@+id/cal_3"182                 app:layout_rowWeight="1"183                 android:text="3"184                 android:onClick="@{cal::numClick}"185                 />186 187             <Button188                 android:id="@+id/cal_equals"189                 app:layout_rowSpan="2"190                 app:layout_rowWeight="1"191                 app:layout_gravity="fill_vertical"192                 android:text="="193                 android:onClick="@{() -> cal.equalsClick()}"194                 />195 196             <Button197                 android:id="@+id/cal_12"198                 android:layout_marginLeft="5dp"199                 app:layout_rowWeight="1"200                 android:text="%"201                 android:onClick="@{() -> cal.percentClick()}"202                 />203 204             <Button205                 android:id="@+id/cal_zero"206                 app:layout_rowWeight="1"207                 android:text="0"208                 android:onClick="@{cal::numClick}"209                 />210 211             <Button212                 android:id="@+id/cal_dot"213                 app:layout_rowWeight="1"214                 android:text="."215                 android:onClick="@{() -> cal.dotClick()}"216                 />217 218         </android.support.v7.widget.GridLayout>219 220     </LinearLayout>221 </layout>
View Code

 

布局内容比较简单,下面,只说一些重点:

  1. DataBinding 的布局中,如果需要使用 tools 标签,它的声明必须放在 layout 节点上。否则,布局预览中没有效果

  2. data 节点中申明的是布局文件各元素需要使用到的对象,也可以为对象定义别名

  3. 布局文件中的控件如果要使用 data 中定义的对象,值的类似于:@{View.VISIBLE} 。控件的属性值中,不仅可以使用对象,还能使用对象的方法

Fragment


MVVM 中,ActivityFragment 的作用只是用于控件的初始化,包括控件属性(如颜色)等的设置。因此,它的代码灰常简单,具体如下:

 

该类中,只有两个方法。

onCreateView 方法用于返回视图,返回的方法与平时使用的 Fragment 略有不同。平时用 View.inflate 方法获取视图并返回,在 DataBinding 下,使用 DataBindingUtil.inflate 方法返回 ViewBinding 对象,然后给该对象对应的布局文件中的变量赋值。

onDestory() 方法中调用了两个释放资源的方法,这两个方法是在 ViewModel 中声明的。

ViewModel


MVVM 中,ViewModel 是重头,它用于处理所有非 UI 的业务逻辑。对于计算器来说,业务逻辑就是数字、符号的输入,数字运算等。具体内容如下:

技术分享
  1 package com.ch.wchhuangya.android.pandora.vm;  2   3 import android.content.Context;  4 import android.databinding.ObservableField;  5 import android.view.View;  6 import android.widget.Button;  7   8 /**  9  * Created by wchya on 2016-12-07 16:17 10  */ 11  12 public class CalculatorVM extends BaseVM { 13  14     /** 用于定义操作符后的空格显示 */ 15     public static final String EMPTY_STR = " "; 16     /** 用于定义结果数字前的显示 */ 17     public static final String EQUALS_EMPTY_STR = "= "; 18  19     /** 被操作数 */ 20     public ObservableField<String> firstNum = new ObservableField<>("0"); 21     /** 上一次结果 */ 22     public ObservableField<String> secondNum = new ObservableField<>(""); 23     /** 当前结果 */ 24     public ObservableField<String> resNum = new ObservableField<>(""); 25  26     /** 被操作数的数值 */ 27     double fNum; 28     /** 上一次结果的数值 */ 29     double sNum; 30     /** 当前结果的数值 */ 31     double rNum; 32     /** 标识当前是否为初始状态 */ 33     boolean initState = true; 34     /** 当前运算符 */ 35     CalOperator mCurOperator; 36     /** 前一运算符 */ 37     CalOperator mPreOperator; 38  39     /** 运算符枚举 */ 40     enum CalOperator { 41         ADD("+"), 42         MINUS("-"), 43         MULTIPLY("×"), 44         DIVIDE("÷"); 45  46         private String value; 47  48         CalOperator(String value) { 49             this.value =http://www.mamicode.com/ value; 50         } 51  52         /** 根据运算符字符串获取运算符枚举 */ 53         public static CalOperator getOperator(String value) { 54             CalOperator otor = null; 55             for (CalOperator operator : CalOperator.values()) { 56                 if (operator.value.equals(value)) 57                     otor = operator; 58             } 59             return otor; 60         } 61     } 62  63     public CalculatorVM(Context context) { 64         mContext = context; 65     } 66  67     /** 68      * 数字点击处理  69      * 当数字变化时,先变化 firstNum,然后计算结果 70      */ 71     public void numClick(View view) { 72         String btnVal = ((Button) view).getText().toString(); 73  74         if (btnVal.equals("0")) { // 当前点击 0 按钮 75             if (firstNum.get().equals("0")) // 当前显示的为 0 76                 return; 77         } 78  79         String originalVal = firstNum.get(); 80         boolean firstIsDigit = Character.isDigit(originalVal.charAt(0)); 81  82         if (isInitState()) { // 初始状态(既刚打开页面或点击了 Clear 之后) 83             handleFirstNum(btnVal, Double.parseDouble(btnVal)); 84             handleResNum(EQUALS_EMPTY_STR + btnVal, Double.parseDouble(btnVal)); 85         } else { 86             if (firstIsDigit) { // 首位是数字,直接在数字后添加 87                 String changedVal = originalVal + btnVal; 88                 handleFirstNum(changedVal, Double.parseDouble(changedVal)); 89                 handleResNum(EQUALS_EMPTY_STR + String.valueOf(fNum), Double.parseDouble(changedVal)); 90             } else { // 首位是运算符,计算结果后显示 91  92                 if (originalVal.length() == 3 && Double.parseDouble(originalVal.substring(2)) == 0L) // 被操作数是 运算符 + 空格 + 0 93                     handleFirstNum(mCurOperator.value + EMPTY_STR, Double.parseDouble(btnVal)); 94                 else 95                     handleFirstNum(originalVal + btnVal, Double.parseDouble((originalVal + btnVal).substring(2))); 96  97                 cal(); 98             } 99         }100         adjustNums();101         setInitState(false);102     }103 104     /** 退格键事件 */105     public void del() {106         String first = firstNum.get();107         if (secondNum.get().length() > 0) { // 正在计算108 109             if (first.length() <= 3) { // firstNum 是运算符,把 secondNum 的值赋值给 firstNum,secondNum 清空110                 handleFirstNum(sNum + "", sNum);111                 handleResNum(EQUALS_EMPTY_STR + secondNum.get(), sNum);112                 handleSecondNum("", 0L);113                 mCurOperator = null;114             } else { // 把最后一个数字删除,重新计算115                 String changedVal = first.substring(0, first.length() - 1);116                 handleFirstNum(changedVal, Double.parseDouble(changedVal.substring(2)));117                 cal();118             }119         } else { // 没有计算120 121             if ((first.startsWith("-") && first.length() == 2) || first.length() == 1) { // 只有一位数字122                 setInitState(true);123                 handleFirstNum("0", 0L);124                 handleResNum("", 0L);125             } else {126                 String changedFirst = first.substring(0, firstNum.get().length() - 1);127                 handleFirstNum(changedFirst, Double.parseDouble(changedFirst));128                 handleResNum(EQUALS_EMPTY_STR + fNum, fNum);129             }130         }131         adjustNums();132     }133 134     /** 运算符点击处理 */135     public void operatorClick(View view) {136         String btnVal = ((Button) view).getText().toString();137 138         // 如果当前有运算符,并且运算符后有数字,把当前运算符赋值给前一运算符139         if (mCurOperator != null && firstNum.get().length() >= 3)140             mPreOperator = mCurOperator;141 142         mCurOperator = CalOperator.getOperator(btnVal);143 144         if (secondNum.get().equals("")) { // 1. 没有 secondNum,把 firstNum 赋值给 secondNum,然后把运算符赋值给 firstNum145 146             handleSecondNum(firstNum.get(), Double.parseDouble(firstNum.get()));147             handleFirstNum(mCurOperator.value + EMPTY_STR, 0L);148         } else { // 2. 有 secondNum149             if (firstNum.get().length() == 2) { // 2.1 只有运算符时,只改变运算符显示,其它不变150 151                 firstNum.set(mCurOperator.value + EMPTY_STR);152             } else { // 2.2 既有运算符,又有 firstNum 和 secondNum 时,计算结果153 154                 if (mPreOperator != null) {155                     mPreOperator = null;156 157                     handleFirstNum(mCurOperator.value + EMPTY_STR, 0L);158                     handleSecondNum(rNum + "", rNum);159                 } else {160                     cal();161                     handleFirstNum(mCurOperator.value + EMPTY_STR, 0L);162                 }163             }164         }165         setInitState(false);166         adjustNums();167     }168 169     /**170      * 点的事件处理171      * 1. 只能有一个点172      * 2. 输入点后,firstNum 的值不变,只改变显示173      */174     public void dotClick() {175         if (firstNum.get().contains("."))176             return;177         else {178             setInitState(false);179             String val = firstNum.get();180 181             if (!Character.isDigit(val.charAt(0)) && val.length() == 2) {182                 handleFirstNum(val + "0.", fNum);183             } else184                 handleFirstNum(val + ".", fNum);185         }186     }187 188     /**189      * 百分号的事件处理190      * 1. 初始状态或刚刚经过 clear 操作时,点击无反应191      * 2. 当 firstNum 为运算符时,点击无反应192      * 3. 其余情况,点击后将 firstNum 乘以 0.01193      */194     public void percentClick() {195         String originalVal = firstNum.get();196         if (isInitState())197             return;198         else if (originalVal.length() == 1 && !Character.isDigit(originalVal.charAt(0)))199                 return;200         else {201             fNum = fNum * 0.01;202             if (mCurOperator != null) {203                 handleFirstNum(mCurOperator.value + " " + fNum, fNum);204                 cal();205             } else {206                 handleFirstNum(String.valueOf(fNum), fNum);207                 handleResNum(String.valueOf(fNum), fNum);208             }209         }210     }211 212     /**213      * 等号事件处理214      * 1. 只有 firstNum,不作任何处理215      * 2. 有 secondNum 时,把 secondNum 和 firstNum 的值进行运算,然后把值赋值给 firstNum,清空 secondNum,216      */217     public void equalsClick() {218         if (!secondNum.get().equals("")) {219             cal();220             handleFirstNum(String.valueOf(rNum), rNum);221             handleSecondNum("", 0L);222         }223         adjustNums();224     }225 226     /** 计算结果 */227     private void cal() {228         switch (mCurOperator) {229             case ADD:230                 rNum = sNum + fNum;231                 handleResNum(EQUALS_EMPTY_STR + rNum, rNum);232                 break;233             case MINUS:234                 rNum = sNum - fNum;235                 handleResNum(EQUALS_EMPTY_STR + rNum, rNum);236                 break;237             case MULTIPLY:238                 rNum = sNum * fNum;239                 handleResNum(EQUALS_EMPTY_STR + rNum, rNum);240                 break;241             case DIVIDE:242                 if (fNum == 0L) {243                     rNum = 0L;244                     handleResNum("= ∞", rNum);245                 } else {246                     rNum = sNum / fNum;247                     handleResNum(EQUALS_EMPTY_STR + rNum, rNum);248                 }249                 break;250         }251         adjustNums();252     }253 254     /**255      * 调整结果,主要将最后无用的 .0 去掉256      */257     private void adjustNums() {258         String ffNum = firstNum.get();259         String ssNum = secondNum.get();260         String rrNum = resNum.get();261         if (ffNum.endsWith(".0")) {262             firstNum.set(ffNum.substring(0, ffNum.length() - 2));263         }264         if (ssNum.endsWith(".0")) {265             secondNum.set(ssNum.substring(0, ssNum.length() - 2));266         }267         if (rrNum.endsWith(".0"))268             resNum.set(rrNum.substring(0, rrNum.length() - 2));269     }270 271     /** 将计算器恢复到初始状态 */272     public void clear() {273         setInitState(true);274 275         handleFirstNum("0", 0L);276 277         handleSecondNum("", 0L);278 279         handleResNum("", 0L);280 281         mCurOperator = null;282     }283 284     /** 处理被操作数的显示和值 */285     private void handleFirstNum(String values, double val) {286         firstNum.set(values);287         fNum = val;288     }289 290     /** 处理上次结果的显示和值 */291     private void handleSecondNum(String values, double val) {292         secondNum.set(values);293         sNum = val;294     }295 296     /** 处理本次结果的显示和值 */297     private void handleResNum(String values, double val) {298         resNum.set(values);299         rNum = val;300     }301 302     public boolean isInitState() {303         return initState;304     }305 306     public void setInitState(boolean initState) {307         this.initState = initState;308     }309 310     @Override311     public void reset() {312         // 释放其它资源313         mContext = null;314 315         // 取掉观察者的注册316         unsubscribe();317     }318 }
View Code

 

要注意的是:ObservableXXX 变量值的获取方法为—— variable.get(),设置方法为:variable.set(xxx)

该类有一个父类:BaseVM, 它用于定义一些通用的变量和子类必须实现的抽象方法。内容如下:

技术分享
 1 package com.ch.wchhuangya.android.pandora.vm; 2  3 import android.content.Context; 4 import android.support.v4.app.Fragment; 5 import android.support.v7.app.AppCompatActivity; 6  7 import java.util.ArrayList; 8 import java.util.List; 9 10 import rx.Subscription;11 12 /**13  * Created by wchya on 2016-11-27 20:3214  */15 16 public abstract class BaseVM {17 18     /** VM 模式中,View 引用的持有 */19     protected AppCompatActivity mActivity;20     /** VM 模式中,View 引用的持有 */21     protected Fragment mFragment;22     /** VM 模式中,上下文引用的持有 */23     protected Context mContext;24     /** 所有用到的观察者 */25     protected List<Subscription> mSubscriptions = new ArrayList<>();26 27     /** 释放持有的资源引用 */28     public abstract void reset();29 30     /** 将所有注册的观察者反注册掉 */31     public void unsubscribe() {32         for (Subscription subscription : mSubscriptions) {33             if (subscription != null && subscription.isUnsubscribed())34                 subscription.unsubscribe();35         }36     }37 }
View Code

 

最终效果如下:

 

技术分享
计算器

 

结束语


本文只是借助计算器这个小应用,把所学的 DataBindingMVVM 的知识使用在实际当中。文中主要使用了 Google 官方 DataBinding 的一些特性,比如为控件设置属性值,为控件绑定事件等。如果读者对这一块内容还不了解,请在官网上查找相关文档进行学习,地址:https://developer.android.com/topic/libraries/data-binding/index.html

笔者在学习时,对官方文档进行了翻译,如果大家对英文文档比较抗拒,可以尝试看一下我的翻译。因为本人能力有限,难免出现错误,欢迎大家用评论的方式告知于我,翻译文档的地址:http://www.cnblogs.com/wchhuangya/p/6031934.html

该应用只是实现了计算器的基本功能,功能不够完善,而且,还有一些缺陷。已知的缺陷有:1. 双精度位数的处理;2. 特别大、特别小数字的显示及处理;这些缺陷只是计算器算法处理上的缺陷,与本文的主题无关,有兴趣的朋友可以将其修改、完善。记着,改好后记得告诉我哦!

路漫漫其修远兮,吾将上下而求索。此话与诸君共勉之!

MVVM 实战之计算器