一、成果展示
-
实现带括号的四则运算
-
可以输入小数(".")、正负数(按"±"键)、对结果开根("√")、输入上次计算的结果("Mr")、对算式和结果清零("CE")、退格("←")
-
处理异常,包括算式格式错误,缺失左右括号,除0错误等
-
切换有理数模式和分数模式
-
登录、注册(尚未完成后端连接数据库)
二、实验内容及步骤
1、需求分析 & 页面布局
页面布局参考iPhone自带的计算器,但是要实现括号按钮,发现排不成好看的矩形。。于是多加了MR和开根的功能。
考虑到要满足有理数计算和分数计算,所以设计一个菜单来切换模式。同时分数的计算无法处理浮点数,正好将小数点键改为/
。
顺便做个登录功能,计划只有用户成功登录以后才能使用分数模式,目前尚未完成。
综上,需要三个Activity,MainActivity实现计算器,LoginActivity实现登录,RegisterActivity实现注册。重点是MainActivity
清单文件如下
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="cn.edu.besti.is.onlinecalculator">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".LoginActivity"
android:label="@string/login_screen_title"
android:parentActivityName=".MainActivity">//ActionBar出现返回键,设置上一级界面
</activity>
<activity android:name=".RegisterActivity"
android:label="注册"
android:parentActivityName=".LoginActivity">
</activity>
</application>
<uses-permission android:name="android.permission.INTERNET" />//允许该应用程序链接网络
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
</manifest>
布局文件activity_main.xml
如下
<?xml version="1.0" encoding="utf-8"?>
<GridLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:useDefaultMargins="false"
android:alignmentMode="alignBounds"
android:columnOrderPreserved="false"
android:layout_gravity="center_horizontal"
android:background="#111"
android:columnCount="4"
android:rowCount="7"
>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="200sp"
android:layout_row="0"
android:layout_column="3">
<TextView
android:id="@+id/textView1"
android:layout_width="match_parent"
android:ellipsize="start"
android:singleLine="true"
android:gravity="center|start"
android:layout_height="90sp"
android:layout_gravity="center_horizontal"
android:background="#111"
android:text=""
android:textAppearance="?android:attr/textAppearanceLarge"
android:textColor="#fff"
android:textSize="45sp" />
<TextView
android:id="@+id/textView2"
android:layout_width="match_parent"
android:layout_height="110sp"
android:layout_gravity="bottom"
android:gravity="end|center"
android:background="#000"
android:text=""
android:singleLine="true"
android:textColor="#fff"
android:textSize="60sp" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_row="1"
android:layout_column="3"
android:layout_gravity="top"
android:orientation="horizontal"
>
<Button
android:id="@+id/button1_1"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="3sp"
android:textSize="45sp"
android:text="CE"
android:background="@drawable/button_style1"
/>
<Button
android:id="@+id/button1_2"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="3sp"
android:textSize="45sp"
android:background="@drawable/button_style1"
android:text="±" />
<Button
android:id="@+id/button1_3"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="3sp"
android:textSize="45sp"
android:background="@drawable/button_style1"
android:text="←" />
<Button
android:id="@+id/button1_4"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_margin="3sp"
android:textSize="45sp"
android:textColor="#fff"
android:background="@drawable/button_style2"
android:text="√" />
</LinearLayout>
···
</GridLayout>
使用GridLayout配合LinearLayout和FrameLayout,FrameLayout包含两个TextView,分别是用户输入的表达式和计算的结果。
每个LinearLayout代表一行按钮,不同的按钮设置不一样的样式,以button_style1.xml
为例
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:state_pressed="true">//按下时的样式
<shape android:shape="rectangle">//圆角按钮
<solid android:color="#eee"/>//颜色
<corners android:radius="8dip"/>//圆角程度
</shape>
</item>
<item android:state_pressed="false">//松开时的样式
<shape android:shape="rectangle">
<solid android:color="#bbb"/>
<corners android:radius="8dip"/>
</shape>
</item>
</selector>
主界面效果如下
其他页面的布局见码云链接
2、MainActivity
package cn.edu.besti.is.onlinecalculator;
import android.content.Intent;
import android.os.StrictMode;
import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import java.util.LinkedList;
public class MainActivity extends AppCompatActivity implements View.OnClickListener {
private Button[] buttons = new Button[23];
private int[] ids = new int[]{
R.id.button1_1, R.id.button1_2, R.id.button1_3, R.id.button1_4,
R.id.button2_1, R.id.button2_2, R.id.button2_3, R.id.button2_4,
R.id.button3_1, R.id.button3_2, R.id.button3_3, R.id.button3_4,
R.id.button4_1, R.id.button4_2, R.id.button4_3, R.id.button4_4,
R.id.button5_1, R.id.button5_2, R.id.button5_3, R.id.button5_4,
R.id.button6_1, R.id.button6_2, R.id.button6_3
};
private TextView textView1, textView2;
private String result = "0";
private LinkedList<String> expr = new LinkedList<>();
private String Mod = "Rational";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().detectNetwork().penaltyLog().build());
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectLeakedSqlLiteObjects().detectLeakedClosableObjects().penaltyLog().penaltyDeath().build());
for (int i = 0; i < ids.length; i++) {
buttons[i] = findViewById(ids[i]);
buttons[i].setOnClickListener(this);
}
this.textView1 = findViewById(R.id.textView1);
this.textView2 = findViewById(R.id.textView2);
}
//onClick方法处理各种点击事件
@Override
public void onClick(View view) {
int id = view.getId();
Button button = view.findViewById(id);
String current = button.getText().toString();
String token;
StringBuilder expression = new StringBuilder();
if (current.equals("CE")) {
expr.clear();
result = "0";
} else if (current.equals("±")) {
if (!expr.isEmpty()) {
token = expr.pollLast();
if (!calcArithmatic.isOperator(token)) {
if (token.contains("-")) {
token = token.replaceAll("-", "");
} else {
token = "-" + token;
}
}
expr.offerLast(token);
}
} else if (current.equals("←")) {
expr.pollLast();
} else if (current.equals(".") || current.equals("/")) {
if (!expr.isEmpty()) {
token = expr.pollLast();
if (!calcArithmatic.isOperator(token)) {
if (!token.contains(current)) {
token += current;
}
}
expr.offerLast(token);
}
} else if (current.equals("=")) {//按下等号时,在本地将中缀表达式转为后缀表达式,传输给服务端,接收服务器的计算结果
if (!expr.isEmpty()) {
for (String s : expr) {
expression.append(" ").append(s);
}
try {
MyBC myBC = new MyBC();
final String formula = myBC.getEquation(expression.toString().trim());
try {
result = Client.Connect(formula, Mod);
} catch (Exception e) {
Toast.makeText(this, "请检查网络连接", Toast.LENGTH_SHORT).show();
}
} catch (ExprFormatException e) {
result = e.getMessage();
} catch (ArithmeticException e0) {
result = "Divide Zero Error";
} finally {
expr.clear();
}
}
} else if (current.equals("√")) {
if (Mod.equals("Rational")) {
result = String.valueOf(Math.sqrt(Double.parseDouble(result)));
}
} else if (current.equals("Mr")) {
if (result.matches("[0-9.\-/]+")) {
current = result;
expr.offerLast(current);
}
} else if (calcArithmatic.isOperator(current)) {
expr.offerLast(current);
} else {
if (!expr.isEmpty()) {
token = expr.pollLast();
if (calcArithmatic.isOperator(token)) {
expr.offerLast(token);
expr.offer(current);
} else {
token += current;
expr.offerLast(token);
}
} else {
expr.offerLast(current);
}
}
for (String s : expr) {
expression.append(" ").append(s);
}
textView1.setText(expression.toString().trim());
textView2.setText(result);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.main, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.option1:
Intent intent = new Intent(this, LoginActivity.class);
startActivity(intent);
return true;
case R.id.option2:
expr.clear();
result = "";
if (item.getTitle().equals("分数模式")) {
buttons[21].setText("/");
item.setTitle("有理数模式");
Mod = "Fraction";
} else {
buttons[21].setText(".");
item.setTitle("分数模式");
Mod = "Rational";
}
return true;
default:
return super.onOptionsItemSelected(item);
}
}
}
-
使用
private LinkedList<String> expr = new LinkedList<>();
来处理用户的每次点击造成的输入,方便将数字和操作符分开,如果队尾元素是数字或小数点,而当前值是数字或小数点,则对队尾元素进行字符串拼接;如果队尾元素是操作符,就直接入队。最后计算结果的时候,依次拼接队中元素,形成中缀表达式。 -
MyBC实现中缀转后缀,大致流程如下,异常处理未体现
具体代码如下
package cn.edu.besti.is.onlinecalculator;
import java.util.EmptyStackException;
import java.util.Stack;
import java.util.StringTokenizer;
class MyBC extends calcArithmatic{
private Stack<String> OpStack;
private String output="";
MyBC(){
OpStack = new Stack<>();
}
private void Shunt(String expr)throws ExprFormatException{
String token;
StringTokenizer tokenizer = new StringTokenizer(expr);
while (tokenizer.hasMoreTokens()){
token=tokenizer.nextToken();
if (isOperator(token)){
if (token.equals(")")){
try{
while (!OpStack.peek().equals("(")) {
output = output.concat(OpStack.pop() + " ");
}
OpStack.pop();
}catch (EmptyStackException e){
throw new ExprFormatException("Missing '('");
}
}
else if (!OpStack.empty()){
if(judgeValue(token)>judgeValue(OpStack.peek()) || token.equals("(")) {
OpStack.push(token);
}
else {
while (!OpStack.empty() && judgeValue(token)<=judgeValue(OpStack.peek())){
output=output.concat(OpStack.pop()+" ");
}
OpStack.push(token);
}
} else {
OpStack.push(token);
}
} else {
output=output.concat(token+" ");
}
}
while (!OpStack.empty()){
if (OpStack.peek().equals("(")){
throw new ExprFormatException("Missing ')'");
}
output=output.concat(OpStack.pop()+" ");
}
}
private int judgeValue(String str){
int value;
switch(str){
case "(":
value=1;
break;
case "+":
case "-":
value=2;
break;
case "×":
case "÷":
value=3;
break;
case ")":
value=4;
break;
default:
value=0;
}
return value;
}
String getEquation(String str) throws ExprFormatException{
Shunt(str);
return output;
}
}
- 输入等号后如下连接到客户端,捕获不同异常来输出不同结果,如果网络连接超时,弹出Toast提示
try {
MyBC myBC = new MyBC();
final String formula = myBC.getEquation(expression.toString().trim());
try {
result = Client.Connect(formula, Mod);
} catch (Exception e) {
Toast.makeText(this, "请检查网络连接",Toast.LENGTH_SHORT).show();
}
} catch (ExprFormatException e) {
result = e.getMessage();
} catch (ArithmeticException e0) {
result = "Divide Zero Error";
} finally {
expr.clear();
}
}
正常来说进行网络请求必须在线程中进行,但是因为我们只是一个小程序,阻塞一下没有什么问题,所以我就直接在主进程里面发送请求,需在MainActivity里面加上如下代码
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().detectDiskReads().detectDiskWrites().detectNetwork().penaltyLog().build());
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder().detectLeakedSqlLiteObjects().detectLeakedClosableObjects().penaltyLog().penaltyDeath().build());
同时Android应用默认不开启网络连接,要在清单文件里声明
<uses-permission android:name="android.permission.INTERNET" />
- 以下代码实现选择菜单功能
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.main, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.option1:
Intent intent = new Intent(this, LoginActivity.class);
startActivity(intent);
return true;
case R.id.option2:
expr.clear();
result = "";
if (item.getTitle().equals("分数模式")) {
buttons[21].setText("/");
item.setTitle("有理数模式");
Mod = "Fraction";
} else {
buttons[21].setText(".");
item.setTitle("分数模式");
Mod = "Rational";
}
return true;
default:
return super.onOptionsItemSelected(item);
}
}
点击切换模式以后,仅仅是将"."按钮的值改成"/",因为在处理点击事件的时候也是根据被点击按钮的值来决定行为的。
同时切换模式后相应的改变菜单里模式按钮的文字。
menu.xml如下,app:showAsAction="never"
决定该菜单按钮的位置,never代表永远折叠在菜单中
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item android:id="@+id/option1"
android:title="登录"
app:showAsAction="never" />
<item android:id="@+id/option2"
android:title="分数模式"
app:showAsAction="never"/>
</menu>
3、Client类
我定义了Client类来完成发送请求和收发数据,在这个过程中进行加密传输,代码如下
package cn.edu.besti.is.onlinecalculator;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
import java.net.*;
public class Client {
public static String Connect(String formula, String mod) throws Exception {
String mode = "AES";
Socket mysocket;
DataInputStream in;
DataOutputStream out;
mysocket = new Socket();
mysocket.connect(new InetSocketAddress("172.30.7.19", 2010),5000);
mysocket.setSoTimeout(5000);
in = new DataInputStream(mysocket.getInputStream());
out = new DataOutputStream(mysocket.getOutputStream());
//使用AES进行后缀表达式的加密
KeyGenerator kg = KeyGenerator.getInstance(mode);
kg.init(128);
SecretKey k = kg.generateKey();//生成密钥
byte[] mkey = k.getEncoded();
Cipher cp = Cipher.getInstance(mode);
cp.init(Cipher.ENCRYPT_MODE, k);
byte[] ptext = formula.getBytes("UTF8");
byte[] ctext = cp.doFinal(ptext);
//将加密后的后缀表达式传送给服务器
String out1 = B_H.parseByte2HexStr(ctext);
out.writeUTF(out1);
//创建客户端DH算法公、私钥
KeyPair keyPair = Key_DH5_6.createPubAndPriKey();
PublicKey pbk = keyPair.getPublic();//Client公钥
PrivateKey prk = keyPair.getPrivate();//Client私钥
//将公钥传给服务器
byte[] cpbk = pbk.getEncoded();
String CpubKey = B_H.parseByte2HexStr(cpbk);
out.writeUTF(CpubKey);
Thread.sleep(1000);
//接收服务器公钥
String SpubKey = in.readUTF();
byte[] spbk = H_B.parseHexStr2Byte(SpubKey);
KeyFactory kf = KeyFactory.getInstance("DH");
PublicKey serverPub = kf.generatePublic(new X509EncodedKeySpec(spbk));
//生成共享信息,并生成AES密钥
SecretKeySpec key = KeyAgree5_6.createKey(serverPub, prk);
//对加密后缀表达式的密钥进行加密,并传给服务器
cp.init(Cipher.ENCRYPT_MODE, key);
byte[] ckey = cp.doFinal(mkey);
String Key = B_H.parseByte2HexStr(ckey);
out.writeUTF(Key);
out.writeUTF(mod);
//接收服务器回答
return in.readUTF();
}
}
如下设置连接请求超时时间为5秒
mysocket.connect(new InetSocketAddress("172.30.7.19", 2010),5000);
如下设置收发数据超时时间为5秒
mysocket.setSoTimeout(5000);
密码学部分参考我搭档的博客
4、服务器端
服务器端简单的用Java实现,代码如下
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.security.*;
import java.security.spec.X509EncodedKeySpec;
public class Server extends Thread {
Socket socketOnServer;
public Server(Socket socketOnServer) {
super();
this.socketOnServer = socketOnServer;
}
public static void main(String[] args) {
ServerSocket serverForClient;
try {
serverForClient = new ServerSocket(2010);
while (true) {
System.out.println(currentThread()+"等待客户呼叫:");
Socket socketOnServer = serverForClient.accept();
new Server(socketOnServer).start();
}
} catch (IOException e1) {
System.out.println(e1.getMessage());
}
}
@Override
public void run() {
String mode = "AES";
DataOutputStream out = null;
DataInputStream in = null;
String result;
try {
out = new DataOutputStream(socketOnServer.getOutputStream());
in = new DataInputStream(socketOnServer.getInputStream());
//接收加密后的后缀表达式
String cformula = in.readUTF();
byte cipher[] = H_B.parseHexStr2Byte(cformula);
//接收Client端公钥
String push = in.readUTF();
byte np[] = H_B.parseHexStr2Byte(push);
KeyFactory kf = KeyFactory.getInstance("DH");
PublicKey ClientPub = kf.generatePublic(new X509EncodedKeySpec(np));
//创建服务器DH算法公、私钥
KeyPair keyPair = Key_DH5_6.createPubAndPriKey();
PublicKey pbk = keyPair.getPublic();//Server公钥
PrivateKey prk = keyPair.getPrivate();//Server私钥
//将服务器公钥传给Client端
byte cpbk[] = pbk.getEncoded();
String CpubKey = B_H.parseByte2HexStr(cpbk);
out.writeUTF(CpubKey);
Thread.sleep(1000);
//生成共享信息,并生成AES密钥
SecretKeySpec key = KeyAgree5_6.createKey(ClientPub, prk);
String k = in.readUTF();//读取加密后密钥
byte[] encryptKey = H_B.parseHexStr2Byte(k);
String mod = in.readUTF();
//对加密后密钥进行解密
Cipher cp = Cipher.getInstance(mode);
cp.init(Cipher.DECRYPT_MODE, key);
byte decryptKey[] = cp.doFinal(encryptKey);
//对密文进行解密
SecretKeySpec plainkey = new SecretKeySpec(decryptKey, mode);
cp.init(Cipher.DECRYPT_MODE, plainkey);
byte[] plain = cp.doFinal(cipher);
//计算后缀表达式结果
String formula = new String(plain);
MyDC myDC = new MyDC(mod);
try {
result = myDC.calculate(formula);
//后缀表达式formula调用MyDC进行求值
} catch (ExprFormatException e) {
result = e.getMessage();
} catch (ArithmeticException e0) {
result = "Divide Zero Error";
}
//将计算结果传给Client端
out.writeUTF(result);
} catch (Exception e) {
System.out.println("客户已断开" + e);
}
}
}
-
服务端必须使用多线程来防止阻塞
-
MyDC流程大致如下,真正在计算时会根据是有理数模式还是分数模式使用不同的计算规则
-
密码学部分同样参考我搭档的博客
5、登录、注册部分
要实现ActionBar出现返回键,在清单文件中相应的Activity下设置parentActivityName
<activity android:name=".LoginActivity"
android:label="@string/login_screen_title"
android:parentActivityName=".MainActivity">//ActionBar出现返回键,设置上一级界面
</activity>
未完待续,随缘更新(这已经超出实验的范围了,我只是随便玩玩)
三、遇到问题及解决
-
问题1:测试时client尝试连接127.0.0.1一直连接不上,服务端一直在等待客户呼叫,没有任何反应。
-
问题1解决:说明根本就没有向我主机发送请求,原来Android程序中尝试连接localhost,程序会将Android手机作为主机,当然连不到我服务端所在的电脑。应该将地址改为内网地址
-
问题2:使用DH算法协商密钥时需要写入文件,但是Android虚拟手机没有写权限。。
-
问题2解决(并没有):没有找到改权限的方法,所以只能直接传输密钥,不经过文件。
-
问题3:测试除0时返回意料之外的结果
-
问题3解决:Java浮点数除0会出现三种情况,NaN、Infinity、-Infinity,参考链接Java浮点数运算两个特殊的情况:NaN,Infinity。为了统一格式,我判断Infinity的情况然后主动抛出除零异常。
四、心得体会
虽然时间不是很充裕,但还是想熬夜敲代码,因为我不确定我到底有没有这个能力完成它,自然要挑战一下。因为没有系统地学过Android,很多地方都是现查现学,参考别人的代码改,总的来说我觉得最后做出来的东西还算比较满意。在这个过程中我更加深入的了解了Android的开发机制,学会了一些小技巧,一些组件的用法等等,同时对Java web编程也有了一定了解,我觉得Android其实和Web编程还是有相似之处的,希望之后能将数据库部分完成,“活学活用”一下。