计算器是继天气预报仿写后的第二个仿写项目,笔者在这之前阅读了AFNetworking部分源码,对于其中一个处理类只对外提供一个接口,一个模块只由对应类负责,层层调用的写法十分感兴趣,于是在本次仿写中尝试使用了这一模式;计算器仿写本身也在算法和字符串处理上对自身的能力做了补全
- 成品如下

总览

以上是我的项目文件,其中我将暂时用不到的文件放在Another中,把核心文件放在Root文件夹中,接下来的讲述中,我会以root文件夹和剩余两个文件夹为核心展开
- 首先可以看到,Root中有C,V子文件夹,这个就是展示界面以及响应交互的MVC架构,其中RootView负责布局展示UI,Controller负责处理点击,由于我使用的是CollectionView来放置按键,所以这里就需要Controller来处理
CalculatorEngine是读取核心,每当点击按键的时候,对应的Controller会传一个点击的结果进入CalculatorEngine,Controller全程不处理任何输入,仅仅是把输入传入CalculatorEngine,由它解析传入的结果并且决定如何拼接CalculatorEngine当读取到点击等于的时候,就把处理完的token数组丢给Parser,让Parser完成计算,最后层层返回Controller再给rootView完成UI更新
这一套流程很好的实现了责任分离,每个文件只负责一个内容
上面涉及到的概念以及具体实现将会在下面展开
MVC内容
这里要提前设置rootVC,然后再仿照计算的UI进行布局,背景等内容比较简单这里不多赘述,按键方面,我采用的是CollectionView,让rootView持有CollectionView,并且在h文件中暴露出来
**暴露出来的原因是:**Controller不应该关心collectionView的样式,布局等等内容,它只关心collectionView的数据源,点击逻辑,这就要求它必须要把CollectionView的delegate和dataSource设置成自己,并且约束和初始化得放在rootView当中,所以就有了这个方法
objc
- (void)setupCollectionView {
self.rootView.collectionView.delegate = self;
self.rootView.collectionView.dataSource = self;
[self.rootView.collectionView registerClass:[BtnCollectionViewCell class] forCellWithReuseIdentifier:@"cell"];
}
其次是数据的初始化,既然选择collectionView布局按键,就需要一个数组来充当数据源,并且这个数组要作为传入参数一会传进Engine里面,大概就是这样子
objc
self.btnText = @[
@"AC",
@"(",
@")",
@"÷",
@"7",
@"8",
@"9",
@"×",
@"4",
@"5",
@"6",
@"-",
@"1",
@"2",
@"3",
@"+",
@"delete.left",
@"0",
@".",
@"=",
];
而rootView中,除了点击按键的collectionView,还要有展示运算式和运算结果的label,同时为了更新UI,还需要弄一个更新方法Config出来,其中最值得一讲的是对label的约束
label设计
我们知道,官方的计算器中输入长串数字一是可以随着输入长度变换字体大小,二是可以在超出屏幕后滑动,第一个比较好实现,那就是在config更新方法里面写一个判断条件更改UI,第二个比较难,实现方法如下
-
使用一个scollView套住label,并且让label凭自己的text长度约束自己的大小,再约束在scrollView里面,这样就实现了长内容的滑动
-
这是RootView中几个方法的内容,我进行了一些删减,仅留下了重点片段
objc
- (void)setupUI {
self.scroll1 = [[UIScrollView alloc] init];
self.scroll1.showsHorizontalScrollIndicator = NO;
[self addSubview:self.scroll1];
[self.scroll1 mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.left.equalTo(self.collectionView);
make.bottom.equalTo(self.collectionView.mas_top).offset(-8);
make.height.mas_equalTo(80);
}];
self.currStr = [[UILabel alloc] init];
self.currStr.textColor = [UIColor whiteColor];
self.currStr.font = [UIFont systemFontOfSize:72 weight:UIFontWeightMedium];
self.currStr.text = @"0";
[self.scroll1 addSubview:self.currStr];
}
- (void)configWithAnswer:(NSString *)answer {
self.currStr.text = answer;
...
[self layoutDisplay];
}
- (void)layoutDisplay {
[self.currStr sizeToFit];
CGFloat labelWidth = self.currStr.bounds.size.width;
CGFloat labelHeight = self.scroll1.bounds.size.height;
CGFloat contentWidth = MAX(labelWidth, self.scroll1.bounds.size.width);
self.scroll1.contentSize = CGSizeMake(contentWidth, labelHeight);
self.currStr.frame = CGRectMake(contentWidth - labelWidth, 0, labelWidth, labelHeight);
CGFloat offsetX = MAX(0, contentWidth - self.scroll1.bounds.size.width);
self.scroll1.contentOffset = CGPointMake(offsetX, 0);
}
scroll1不必多说就是scrollView,currStr就是label
我们看到,我只对scrollView进行了约束,这是因为无论怎么滑动,总得有一个固定的窗口,所以scrollView一定是定死的,而可以改变的是scrollView里面的contentsize大小和label在里面的位置,而每次更新内容的时候,需要重新对这个scrollView的contentsize进行约束,所以就有了layoutDisplay这个方法,我们进入其中看看
[self.currStr sizeToFit]这个就是让label大小跟随内容大小,接着下面获取了label的真实宽度,而高度并不改变直接拿scrollVoew的高度- 获取填充宽度,这一步之需要拿到scrollview和label两者最大值就行,如果label短肯定填充不满,然后直接可以定下contentsize的大小
- 把label放上去,这一步就是基于scrollview进行的,使用CGSizeMake的话,就要从左和上开始,所以起点就是本身contentwidth的全长,减去label的真实长,有人这里会问,为什么不直接放在scrollView的左上呢,这是因为如果label长度比较短,那不就约束到左边去了吗
- 最后是调整初始偏移量,让文字的最右边和scrollView的最右侧对齐,这里偏移到刚好剩下一个窗口的长度就行,并且为了避免负数,和0取一个最大值
计算逻辑与分工
写下计算部分的代码以前,我们要先明确计算的方式,在计算器中一般采用的都是逆波兰表达式,也就是后缀计算式,而我们平时采用的都是中缀表达式,如1+1是中缀表达式,为了计算,就要先转化为后缀表达式:11+这样的好处就是计算机不用顾虑运算优先级而可以采用一个流程循环的处理一整个式子,这一点在后面计算逻辑会详细解释
但是我们需要看到的输入以及处理的输入却是适合人类的中缀表达式,所以就需要一个中缀转后缀的过程
- 并且从刚刚那个例子也可以看出来,如果想要中缀转后缀,就需要把每一个数,和运算符分隔开然后进行重组,这用string肯定是做不到的,事实上,数组是最适合做这个工作的,我们可以这样组织数据:把每一个数字,运算符都放进一个名为token的数组中,如
objc
token = @[
@"1",
@"+",
@"1"
];
//伪代码
- 然后把token数组进行内部重组,最后计算出token数组的所有内容
下面的目标就很清晰了:
- 处理输入,把点击的内容从Controller传入CalculatorEngine
- CalculatorEngine处理输入,把陆续输入的字符或数组拼接成token数组
- 点击=时把token数组从CalculatorEngine传入Parser
- Parser把token数组从中缀转为后缀,计算后缀
- 把计算结果传回controller
具体实现
Controller
这里主要在点击时把字符传入CalculatorEngine,并且接收计算的结果用于更新UI
objc
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
NSString *text = self.btnText[indexPath.item];
[self.rootView configWithAnswer:[self.engine input:text]];
//下面两句用于添加点击反馈的,与实际逻辑无关
BtnCollectionViewCell *cell = [collectionView cellForItemAtIndexPath:indexPath];
[cell tapped];
}
重点看第二句,[self.rootView configWithAnswer:[self.engine input:text]];我直接将CalculatorEngine的内容传入rootView的config方法里面更新,只需要一句代码,其余内容让CalculatorEngine调用完成,注意这里self.engine是提前初始化过的
- 接下来我们进入CalculatorEngine的对应方法中
CalculatorEngine
- (NSString *)input:(NSString *)input;这是该类仅向外提供的唯一一个接口
在处理输入前,我们得想清楚几个问题:
- 输入数字时我们不能直接把用户的输入放进token数组,因为有可能后面还要加数字,比如输入一个2,不能直接放进token因为用户可能想输入22
- 不能连续输入两个加号,还有其他的禁止输入的情况如不能直接输入右括号等等还有很多情况,
- 返回的字符串需要像用户展示当前输入
针对第一个问题,我们的解决办法是,维护一个临时的string,用于存放数字,当确认数字输入完了,直接放入token
- 比如这样;
- 输入2 token:{ } currentNumber : "2"
- 输入2. token: { }. currentNumber : "22"
- 输入+. token : { "22", "+"}. currentNumber : ""
针对第二个问题,我们需要用枚举维护一个状态,什么状态?前一个输入的是什么,比如前一个输入的是+,那么我在下一个输入的时候就不能输入=,也不能输入小数点
- 这里是枚举状态的定义和初始化
objc
typedef enum : NSUInteger {//维护上一次的输入内容是什么
NumberInput,
OperatorInput,
EqualInput,
PointInput,
LeftInput,
RightInput,
ACInput,
NegativeInput,
nilInput
} CalculatorMode;
- (instancetype)init
{
self = [super init];
if (self) {
self.token = [NSMutableArray array];
self.currentNumber = [NSMutableString string];
self.canPoint = YES;
self.leftCount = 0;
[self input:@"AC"];
self.parser = [[Parser alloc] init];
}
return self;
}
第三个问题就需要每次输入都先返回当前token数组与currentNumber的拼接string,用于展示暂时的内容
分析完毕,接下来就是实现
- 这里是input接口的实现
objc
- (NSString *)input:(NSString *)input {
if ([self isNumber:input]) {
return [self inputNumber:input];
}
if ([self isOperator:input]) {
return [self inputOperator:input];
}
if ([self isEqualInput:input]) {
return [self inputEqual];
}
if ([self isAC:input]) {
if (self.clear) {
self.clear();
}
return [self inputAC];
}
if ([self isPoint:input]) {
return [self inputPoint:input];
}
if ([self isLeft:input]) {
return [self inputLeft:input];
}
if ([self isRight:input]) {
return [self inputRight:input];
}
if ([self isDelete:input]) {
return [self inputDelete];
}
if ([input isEqualToString:@"inf"]) {
self.mode = LeftInput;
self.currentNumber = [NSMutableString string];
self.canPoint = YES;
self.leftCount = 0;
return @"inf";
}
return @"0";
}
可以看到这里做了很多判断,其实就是判断输入的类型,确认类型,然后与枚举配合就可以知道这里能不能写下
接着就是拼接方法
objc
- (NSString *)appendStrings {
for (NSInteger i = 0; i < self.token.count; i++) {
NSMutableString *str = self.token[i];
if (!([str isEqualToString:@"+"] || [str isEqualToString:@"-"] || [str isEqualToString:@"×"] || [str isEqualToString:@"÷"] || [str isEqualToString:@"("] || [str isEqualToString:@")"])) {
self.token[i] = [NSString stringWithFormat:@"%g", [str doubleValue]];
}
}
NSMutableString *str = [NSMutableString string];
for (NSString *nums in self.token) {
[str appendString:nums];
}
[str appendString:self.currentNumber];
return str;
}
这个方法可以让每次调用input的具体内容时可以无脑调用然后返回目前存着的所有东西,那个for循环是为了消去前导0以及其他无意义内容
- 我们以输入数字为例,
objc
- (NSString *)inputNumber:(NSString *)input {
if (self.mode == ACInput || self.mode == EqualInput) {
self.canPoint = YES;
self.currentNumber = [NSMutableString string];
[self.currentNumber appendString:input];
self.mode = NumberInput;
return [self appendStrings];
} else if (self.mode == RightInput) {
self.currentNumber = [NSMutableString string];
[self.token addObject:@"×"];
[self.currentNumber appendString:input];
self.mode = NumberInput;
return [self appendStrings];
}
[self.currentNumber appendString:input];
self.mode = NumberInput;
return [self appendStrings];
}
如果输入的是数字,前一个是数字的情况就直接拼在currentNumber后面,如果是刚被清除或者刚算出来,就直接替换,如果是右括号的话,就需要在中间加一个乘号,这里的逻辑并不复杂,感兴趣的读者自行观看
- 点击"="
objc
- (NSString *)inputEqual {
if (self.mode == NumberInput || self.mode == RightInput || self.mode == PointInput) {
if (self.mode == PointInput) {
[self.currentNumber deleteCharactersInRange:NSMakeRange(self.currentNumber.length - 1, 1)];
}
self.canPoint = NO;
if (self.currentNumber.length > 0) {
[self.token addObject:self.currentNumber];
}
self.currentNumber = [NSMutableString string];
self.mode = EqualInput;
for (NSInteger i = self.leftCount; i > 0; i--) {
[self.token addObject:@")"];
}
self.leftCount = 0;
if (self.upLoad) {
self.upLoad([self appendStrings]);
}
NSString *result = [self.parser outPutWithInput:self.token];
[self.token removeAllObjects];
[self.currentNumber appendString:result];
NSString *ret = [self appendStrings];
if ([result isEqualToString:@"未定义"] || [result isEqualToString:@"错误"] || [result isEqualToString:@"0"]) {
self.currentNumber = [NSMutableString string];
[self.currentNumber appendString:@"0"];
}
return ret;
}
return [self appendStrings];
}
点击等于的时候,就需要调用Parser了,但在这之前注意要把还没放进token数组的currentNumber放进去,这个代表了最后一个数字,并且还要在右边补上所有的右括号,通过NSString *result = [**self**.parser outPutWithInput:**self**.token];把已经整理好的中缀表达式传进Parser
接下来我们进入Parser
Parser
objc
#import "Parser.h"
@implementation Parser
- (NSString *)outPutWithInput:(NSArray *)outPut {
double result = [self meddleToBehind:[self formateNegative:outPut]];
if (result == 0) {
return @"0";
}
if (result == INFINITY || result == -INFINITY) {
return @"未定义";
}
if ([@"nan" isEqualToString:[NSString stringWithFormat:@"%g", result]]) {
return @"错误";
}
return [NSString stringWithFormat:@"%g", result];
}
- (NSArray *)formateNegative:(NSArray *)input {
NSMutableArray *token = [input mutableCopy];
for (int i = 0; i < token.count; i++) {
NSString *str = token[i];
if (i == 0) {
if ([str isEqualToString:@"-"]) {
token[i] = @"_";
}
continue;
}
NSString *prev = token[i - 1];
if ([str isEqualToString:@"-"] && ( [prev isEqualToString:@"+"] || [prev isEqualToString:@"-"] || [prev isEqualToString:@"×"] || [prev isEqualToString:@"÷"] || [prev isEqualToString:@"("])) {
token[i] = @"_";
}
}
return token;
}
- (double)meddleToBehind:(NSArray *)input {
NSMutableArray *output = [NSMutableArray array];
NSMutableArray *stack = [NSMutableArray array];
for (NSString *token in input) {
if ([self isNumber:token]) {
[output addObject:token];
continue;
}
if ([token isEqualToString:@"("]) {
[stack addObject:token];
continue;
}
if ([token isEqualToString:@")"]) {
NSString *item = stack.lastObject;
while (![item isEqualToString:@"("]) {
[stack removeLastObject];
[output addObject:item];
item = stack.lastObject;
}
[stack removeLastObject];
continue;
}
NSString *last = stack.lastObject;
if ([last isEqualToString:@"("]) {
[stack addObject:token];
continue;
}
if (stack.count == 0) {
[stack addObject:token];
continue;
}
NSInteger myself = [self numberOfOperator:token];
NSInteger your = [self numberOfOperator:last];
if (myself > your) {
[stack addObject:token];
} else {
while (stack.count > 0 && myself <= your) {
[output addObject:stack.lastObject];
[stack removeLastObject];
NSString *la = stack.lastObject;
if ([la isEqualToString:@"("]) {
break;
}
your = [self numberOfOperator:la];
}
[stack addObject:token];
}
}
while (stack.count > 0) {
[output addObject:stack.lastObject];
[stack removeLastObject];
}
return [self answerFromFix:output];
}
- (BOOL)isNumber:(NSString *)str {
if ([str isEqualToString:@"+"] || [str isEqualToString:@"-"] || [str isEqualToString:@"×"] || [str isEqualToString:@"÷"] || [str isEqualToString:@"("] || [str isEqualToString:@")"] || [str isEqualToString:@"_"]) {
return NO;
}
return YES;
}
- (NSInteger)numberOfOperator:(NSString *)operator {
if ([operator isEqualToString:@"+"] || [operator isEqualToString:@"-"]) {
return 1;
}
if ([operator isEqualToString:@"×"] || [operator isEqualToString:@"÷"]) {
return 2;
}
if ([operator isEqualToString:@"_"]) {
return 3;
}
return 0;
}
- (double)answerFromFix:(NSArray *)input {
NSMutableArray *stack = [NSMutableArray array];
for (NSString *str in input) {
if ([self isNumber:str]) {
CGFloat number = [str doubleValue];
[stack addObject:@(number)];
continue;
}
double result = [stack.lastObject doubleValue];
[stack removeLastObject];
if ([str isEqualToString:@"+"]) {
result += [stack.lastObject doubleValue];
} else if ([str isEqualToString:@"-"]) {
result = [stack.lastObject doubleValue] - result;
} else if ([str isEqualToString:@"×"]) {
result *= [stack.lastObject doubleValue];
} else if ([str isEqualToString:@"÷"]) {
result = [stack.lastObject doubleValue] / result;
} else {
result = -1 * result;
[stack addObject:@(result)];
continue;
}
[stack removeLastObject];
[stack addObject:@(result)];
}
return [stack.lastObject doubleValue];
}
@end
- 这里就是Parser的全部内容,首先获取token数组,接着转后缀,转后缀的流程由以下几个规则建立
- 转后缀的过程需要用到两个数组,一个是结果数组,一个是运算符栈,我们需要遍历token数组,遇到数字直接放进结果数组,如果是运算符:
- 如果运算符栈空,直接压栈,如果token遍历到的运算符优先级大于运算符栈顶运算符的优先级,就直接压进运算符栈,否则一直弹运算符栈,把弹出来的内容放进结果数组,直到满足条件把当前运算符压运算符栈
- 在这之前要先处理一下负号的情况,把负号用其他字符替换,比如_或者#,最后计算遇到这个直接取相反数就行
- 如果是括号,遇到左括号直接把左括号压进运算符栈,等遇到右括号的时候一直弹运算符栈,把弹出来的结果放进结果数组,直到遇到左括号然后把左括号扔掉
- 关于符号优先级可以看我的
- (NSInteger)numberOfOperator:(NSString *)operator方法
经过上面的步骤就产生了后缀表达式,那么后缀表达式如何计算呢,这里还是要用到栈
- 按顺序遍历后缀表达式,遇到数字直接入栈,遇到符号(非负号)就拿出来俩数字进行计算,遇到负号就取相反数,把计算出来的结果放回到栈里,最后当遍历完后缀表达式的时候,栈顶就是最终结果
这样就可以得到最终的计算结果,然后传回给CalculatorEngine,再由CalculatorEngine传回给Controller再给rootView更新UI即可