首页 > 代码库 > MVVM 实战之计算器
MVVM 实战之计算器
MVVM 实战之计算器
- Model
- View
- 布局文件
- Fragment
- ViewModel
- 结束语
前些日子,一直在学习基于 RxAndroid + Retrofit + DataBinding
技术组合的 MVVM
解决方案。初识这些知识,深深被它们的巧妙构思和方便快捷所吸引,心中颇为激动。但是,“纸上得来终觉浅,绝知此事要躬行”,学习完以后心里还是没有谱,于是,决定自己动手做一个基于这些技术和框架的小应用。
既然是对新技术学习和掌握的练习,因此,摊子不宜铺的太大。经过思量,最终决定使用 DataBinding
技术构建一个小的 MVVM
应用。MVVM
就是 Model-View-ViewModel
的缩写,与 MVC
模式相比,把其中的 Control
更换为 ViewModel
了。MVVM
的特点:Model
与 View
之间完全没有直接的联系,但是,通过 ViewModel
,Model
的变化可以反映在 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>
布局内容比较简单,下面,只说一些重点:
DataBinding
的布局中,如果需要使用tools
标签,它的声明必须放在layout
节点上。否则,布局预览中没有效果data
节点中申明的是布局文件各元素需要使用到的对象,也可以为对象定义别名布局文件中的控件如果要使用
data
中定义的对象,值的类似于:@{View.VISIBLE}
。控件的属性值中,不仅可以使用对象,还能使用对象的方法
Fragment
在 MVVM
中,Activity
或 Fragment
的作用只是用于控件的初始化,包括控件属性(如颜色)等的设置。因此,它的代码灰常简单,具体如下:
该类中,只有两个方法。
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 }
要注意的是: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 }
最终效果如下:
结束语
本文只是借助计算器这个小应用,把所学的 DataBinding
和 MVVM
的知识使用在实际当中。文中主要使用了 Google
官方 DataBinding
的一些特性,比如为控件设置属性值,为控件绑定事件等。如果读者对这一块内容还不了解,请在官网上查找相关文档进行学习,地址:https://developer.android.com/topic/libraries/data-binding/index.html 。
笔者在学习时,对官方文档进行了翻译,如果大家对英文文档比较抗拒,可以尝试看一下我的翻译。因为本人能力有限,难免出现错误,欢迎大家用评论的方式告知于我,翻译文档的地址:http://www.cnblogs.com/wchhuangya/p/6031934.html。
该应用只是实现了计算器的基本功能,功能不够完善,而且,还有一些缺陷。已知的缺陷有:1. 双精度位数的处理;2. 特别大、特别小数字的显示及处理;这些缺陷只是计算器算法处理上的缺陷,与本文的主题无关,有兴趣的朋友可以将其修改、完善。记着,改好后记得告诉我哦!
路漫漫其修远兮,吾将上下而求索。此话与诸君共勉之!
MVVM 实战之计算器