MVVM 实战之计算器
MVVM 实战之计算器
前些日子,一直在学习基于 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 <Button 101 android:id="@+id/cal_multiply" 102 app:layout_rowWeight="1" 103 android:text="×" 104 android:onClick="@{cal::operatorClick}" 105 /> 106 107 <Button 108 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 <Button 116 android:id="@+id/cal_8" 117 app:layout_rowWeight="1" 118 android:text="8" 119 android:onClick="@{cal::numClick}" 120 /> 121 122 <Button 123 android:id="@+id/cal_9" 124 app:layout_rowWeight="1" 125 android:text="9" 126 android:onClick="@{cal::numClick}" 127 /> 128 129 <Button 130 android:id="@+id/cal_minus" 131 app:layout_rowWeight="1" 132 android:text="-" 133 android:onClick="@{cal::operatorClick}" 134 /> 135 136 <Button 137 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 <Button 145 android:id="@+id/cal_5" 146 app:layout_rowWeight="1" 147 android:text="5" 148 android:onClick="@{cal::numClick}" 149 /> 150 151 <Button 152 android:id="@+id/cal_6" 153 app:layout_rowWeight="1" 154 android:text="6" 155 android:onClick="@{cal::numClick}" 156 /> 157 158 <Button 159 android:id="@+id/cal_add" 160 app:layout_rowWeight="1" 161 android:text="+" 162 android:onClick="@{cal::operatorClick}" 163 /> 164 165 <Button 166 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 <Button 174 android:id="@+id/cal_2" 175 app:layout_rowWeight="1" 176 android:text="2" 177 android:onClick="@{cal::numClick}" 178 /> 179 180 <Button 181 android:id="@+id/cal_3" 182 app:layout_rowWeight="1" 183 android:text="3" 184 android:onClick="@{cal::numClick}" 185 /> 186 187 <Button 188 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 <Button 197 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 <Button 205 android:id="@+id/cal_zero" 206 app:layout_rowWeight="1" 207 android:text="0" 208 android:onClick="@{cal::numClick}" 209 /> 210 211 <Button 212 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
的作用只是用于控件的初始化,包括控件属性(如颜色)等的设置。因此,它的代码灰常简单,具体如下:
1 package com.ch.wchhuangya.android.pandora.view.activity.calculator; 2 3 import android.databinding.DataBindingUtil; 4 import android.os.Bundle; 5 import android.support.annotation.Nullable; 6 import android.support.v4.app.Fragment; 7 import android.view.LayoutInflater; 8 import android.view.View; 9 import android.view.ViewGroup; 10 11 import com.ch.wchhuangya.android.pandora.R; 12 import com.ch.wchhuangya.android.pandora.databinding.CalculatorBinding; 13 import com.ch.wchhuangya.android.pandora.vm.CalculatorVM; 14 15 /** 16 * Created by wchya on 2016-12-07 16:17 17 */ 18 19 public class CalculatorFragment extends Fragment { 20 21 private CalculatorBinding mBinding; 22 private CalculatorVM mCalVM; 23 24 @Nullable 25 @Override 26 public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { 27 mBinding = DataBindingUtil.inflate(inflater, R.layout.calculator, container, false); 28 mCalVM = new CalculatorVM(getContext()); 29 mBinding.setCal(mCalVM); 30 return mBinding.getRoot(); 31 } 32 33 @Override 34 public void onDestroy() { 35 super.onDestroy(); 36 mCalVM.reset(); 37 } 38 }
该类中,只有两个方法。
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 = 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,然后把运算符赋值给 firstNum 145 146 handleSecondNum(firstNum.get(), Double.parseDouble(firstNum.get())); 147 handleFirstNum(mCurOperator.value + EMPTY_STR, 0L); 148 } else { // 2. 有 secondNum 149 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 } else 184 handleFirstNum(val + ".", fNum); 185 } 186 } 187 188 /** 189 * 百分号的事件处理 190 * 1. 初始状态或刚刚经过 clear 操作时,点击无反应 191 * 2. 当 firstNum 为运算符时,点击无反应 192 * 3. 其余情况,点击后将 firstNum 乘以 0.01 193 */ 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 @Override 311 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:32 14 */ 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. 特别大、特别小数字的显示及处理;这些缺陷只是计算器算法处理上的缺陷,与本文的主题无关,有兴趣的朋友可以将其修改、完善。记着,改好后记得告诉我哦!
路漫漫其修远兮,吾将上下而求索。此话与诸君共勉之!