1. 项目概述:为什么用行空板做计算器?
最近在整理工作室的物料,翻出了几块之前玩过的行空板。这玩意儿本质上是一块集成了屏幕、按键、Wi-Fi和丰富传感器的微型Linux电脑,通常被用在创客教育和物联网原型开发里。看着它那块2.8英寸的触摸屏和几个物理按键,我就在想,除了跑跑Python脚本、连个传感器做个数据看板,它还能干点啥更“接地气”的事儿?一个念头冒出来:给它做个计算器怎么样?
你可能会觉得,这年头谁还缺计算器?手机、电脑、甚至手表上都有。但这事儿的意义不在于“缺”,而在于“做”。对于刚接触行空板、Python编程,甚至是嵌入式开发的朋友来说,做一个计算器是一个绝佳的练手项目。它麻雀虽小,五脏俱全:涉及到用户界面(UI)的绘制、触摸或按键事件的响应、业务逻辑(四则运算)的实现、以及状态的管理。整个过程没有复杂的网络通信或硬件驱动,能让你把注意力完全集中在软件交互逻辑上,非常适合作为第一个综合性小应用来实践。
我这个“计算器(一)”的版本,定位是基础功能实现。目标很明确:在行空板的屏幕上,显示一个仿实体计算器的界面,支持基本的加、减、乘、除运算,能够连续计算,并且有清空(C)和退格(CE)功能。我会基于行空板官方推荐的Python库pinpong和unihiker来实现,整个过程会从界面设计、事件绑定一直讲到运算逻辑的细节处理。如果你手头正好有一块行空板,或者对用Python做图形化小应用感兴趣,那跟着走一遍,收获会比单纯看文档大得多。
2. 核心思路与方案选型
2.1 为什么选择行空板与Unihiker GUI库?
行空板作为硬件载体,其优势在于“开箱即用”。它自带屏幕、电池管理、扬声器,甚至还有加速度计和光线传感器,我们这次只用它的屏幕和触摸功能。在软件层面,行空板官方主推的编程方式是Python,并且为图形化界面提供了unihiker库。这个库是对tkinter的一个封装和增强,专门针对行空板这种小屏幕设备做了优化,提供了更简单的API来创建按钮、标签、图像等控件。
为什么不直接用tkinter或者更复杂的PyQt?核心原因是“复杂度与需求匹配”。unihiker的API极其简洁,比如创建一个带事件的按钮,可能只需要两三行代码,它自动处理了控件在屏幕上的布局和缩放,非常适合快速原型开发。而tkinter原生的布局管理(pack, grid, place)在小屏幕上需要更精细的调整,PyQt则显得杀鸡用牛刀。我们的计算器界面元素固定,交互直接,unihiker的简单高效正是我们所需要的。
2.2 计算器的核心逻辑架构设计
一个计算器,别看界面简单,其内部状态管理是关键。我们不能简单地用一个变量存储当前输入的数字,然后遇到运算符就计算。需要考虑连续运算、等号触发、以及新的数字输入如何影响当前状态。
我采用的是“双操作数+运算符”的经典模型,并引入一个last_input状态来区分输入类型。具体来说,需要维护几个核心变量:
current_display: 字符串,当前屏幕上显示的内容。operand_a: 浮点数,存储第一个操作数(或上一次运算的结果)。operand_b: 浮点数,存储第二个操作数。current_operator: 字符串,存储当前选定的运算符(‘+’, ‘-’, ‘*’, ‘/’)。waiting_for_operand: 布尔值,标志是否在输入新的操作数(例如,按了运算符之后,下一次数字输入应该开启一个新的操作数)。last_input_type: 字符串,记录上一次按键的类型(‘number’, ‘operator’, ‘equals’, ‘clear’),这对于处理连续按等号、连续按运算符等边界情况至关重要。
这个模型能清晰地处理3 + 4 = 7, 再按+ 5 = 12这样的连续运算。当用户按下等号后,operand_a会更新为结果7,并且状态变为等待新的操作数。此时再按+, 运算符被记录,接着输入5,它会被当作新的operand_b, 再次按等号时,计算的就是7 + 5。
2.3 界面布局规划
行空板的屏幕分辨率是240*320。我们需要合理规划空间。一个典型的计算器界面分为:
- 显示区:顶部区域,用于显示输入的数字、运算符和结果。考虑到可能显示较长的数字或表达式,需要选择合适的字体大小,并确保文本右对齐。
- 按键区:网格状排列按钮。通常采用4x5或5x5的布局。我们的基础版包括:
- 数字键 0-9
- 小数点键 ‘.’
- 运算符键 ‘+’, ‘-’, ‘*’, ‘/’
- 功能键:清空 ‘C’、退格 ‘CE’、等号 ‘=’
使用unihiker的GUI对象创建画布,然后使用add_button方法在指定坐标创建按钮。布局时,需要精确计算每个按钮的左上角坐标 (x, y) 和宽高 (w, h),以确保排列整齐且触摸区域不重叠。我会采用一个二维列表来定义按钮的标签和其对应的坐标,这样代码更清晰,也便于后期调整。
3. 开发环境准备与核心代码解析
3.1 连接行空板与基础代码框架
首先,确保你的行空板已经通过USB线连接到电脑,并且安装了官方驱动。通常,行空板会作为一个网络设备出现,你可以通过Wi-Fi或USB网络(RNDIS)访问它的IP,使用VSCode配合SSH插件进行远程开发,或者使用官方的“行空板”软件进行项目上传和运行。这里我推荐使用VSCode远程开发,体验更接近本地。
创建一个新的Python文件,比如calculator_v1.py。开头导入必要的库:
from unihiker import GUI import time初始化GUI对象和屏幕对象:
gui = GUI() screen = gui.canvas # 获取画布对象,用于绘制静态文本接下来,定义我们之前讨论的核心状态变量:
# 计算器状态变量 current_display = "0" # 当前显示字符串 operand_a = 0.0 operand_b = 0.0 current_operator = None waiting_for_operand = True # 初始状态为等待输入第一个操作数 last_input_type = 'clear' # 上一次输入类型current_display初始为 “0”, 这是计算器的常规起始状态。waiting_for_operand为True意味着下一次数字输入将替换当前显示,而不是追加。
3.2 显示区域的设计与更新
显示区域我们用一个unihiker的Text控件来实现,但更简单的方式是直接用画布的draw_text方法绘制,并提供一个更新函数。
# 定义显示区域的位置和属性 display_x, display_y = 10, 20 display_width, display_height = 220, 50 display_font = ("Arial", 24, "bold") display_anchor = "e" # 文本右对齐 # 在画布上绘制一个背景矩形(可选,视觉上更清晰) screen.create_rectangle(display_x, display_y, display_x + display_width, display_y + display_height, outline="#ccc", fill="#f5f5f5") # 初始化显示文本 display_text_id = screen.draw_text(x=display_x + display_width - 5, y=display_y + display_height // 2, text=current_display, font=display_font, anchor=display_anchor)关键在于update_display()函数。它不仅要更新屏幕上的文本,还要处理显示格式,比如过长的数字可能需要缩小字体或使用科学计数法(我们基础版先做简单的截断)。
def update_display(): global display_text_id # 先删除旧的文本 screen.delete(display_text_id) # 处理显示内容:如果过长,截断。实际项目可以考虑滚动或缩放字体。 display_content = current_display if len(display_content) > 12: # 假设最多显示12个字符 display_content = display_content[-12:] # 显示最后12位 # 绘制新的文本 display_text_id = screen.draw_text(x=display_x + display_width - 5, y=display_y + display_height // 2, text=display_content, font=display_font, anchor=display_anchor)这个函数会在每次按键事件处理后被调用,刷新屏幕显示。
3.3 按键的创建与事件绑定
这是界面部分最繁琐但规律性最强的一步。我们定义一个列表来描述按键布局:
# 按钮定义: [行, 列, 文本, 宽度比例, 高度比例, 背景色] # 布局为5行4列 button_defs = [ # 第一行 (0, 0, "C", 1, 1, "#ff6b6b"), # 清空,红色 (0, 1, "CE", 1, 1, "#ffa726"), # 退格,橙色 (0, 2, "/", 1, 1, "#e0e0e0"), # 除法 (0, 3, "*", 1, 1, "#e0e0e0"), # 乘法 # 第二行 (1, 0, "7", 1, 1, "#fff"), (1, 1, "8", 1, 1, "#fff"), (1, 2, "9", 1, 1, "#fff"), (1, 3, "-", 1, 1, "#e0e0e0"), # 第三行 (2, 0, "4", 1, 1, "#fff"), (2, 1, "5", 1, 1, "#fff"), (2, 2, "6", 1, 1, "#fff"), (2, 3, "+", 1, 1, "#e0e0e0"), # 第四行 (3, 0, "1", 1, 1, "#fff"), (3, 1, "2", 1, 1, "#fff"), (3, 2, "3", 1, 1, "#fff"), (3, 3, "=", 1, 2, "#4fc3f7"), # 等号,蓝色,跨两行 # 第五行 (4, 0, "0", 2, 1, "#fff"), # 0键,跨两列 (4, 2, ".", 1, 1, "#fff"), ]然后,我们根据这个定义,循环创建按钮。需要计算每个按钮的具体坐标和大小:
# 布局参数 button_start_x, button_start_y = 10, 80 button_width, button_height = 50, 50 button_gap = 5 buttons = {} # 用于存储按钮对象,键为按钮文本 for row, col, text, col_span, row_span, bg_color in button_defs: x = button_start_x + col * (button_width + button_gap) y = button_start_y + row * (button_height + button_gap) w = button_width * col_span + button_gap * (col_span - 1) h = button_height * row_span + button_gap * (row_span - 1) btn = gui.add_button(x=x, y=y, w=w, h=h, text=text, font=("Arial", 18), bg_color=bg_color, fg_color="#000" if bg_color != "#fff" else "#000", clicked=lambda t=text: on_button_click(t)) buttons[text] = btn这里有两个关键点:
- 坐标计算:根据行、列索引以及跨度,精确计算每个按钮的
(x, y, w, h)。 - 事件绑定:
clicked参数接收一个函数。我们使用lambda函数来为每个按钮传递其对应的文本标签t给事件处理函数on_button_click。这是为了避免所有按钮都共享同一个变量引用导致错误。
4. 核心运算逻辑的实现与状态管理
4.1 事件分发函数on_button_click
所有按钮的点击事件都汇聚到这个函数。它根据传入的按钮文本value进行分流处理:
def on_button_click(value): global current_display, operand_a, operand_b, current_operator, waiting_for_operand, last_input_type if value in "0123456789": input_number(value) elif value == '.': input_decimal() elif value in '+-*/': input_operator(value) elif value == '=': calculate_result() elif value == 'C': clear_all() elif value == 'CE': clear_entry() else: # 理论上不会到这里 pass # 每次按键操作后,更新屏幕显示 update_display()4.2 数字输入处理input_number
这是逻辑的基石。它需要根据waiting_for_operand状态来决定是替换当前显示还是追加数字。
def input_number(num): global current_display, waiting_for_operand, last_input_type if waiting_for_operand: # 如果正在等待一个新的操作数(比如刚按完运算符或等号) current_display = num waiting_for_operand = False else: # 否则,在现有显示后追加数字,但需防止过长 if current_display == "0": # 如果当前显示是“0”,输入数字应替换它,而不是变成“01” current_display = num else: # 正常追加,这里可以加一个长度限制 if len(current_display) < 12: # 限制输入长度 current_display += num last_input_type = 'number'注意:这里有一个常见的坑。如果连续按运算符(比如
3 + - *),按照我们waiting_for_operand的逻辑,在按了第一个+后,状态变为等待操作数。此时再按-, 会触发input_operator, 它会执行上一次运算(3+?, 但?不存在),可能导致错误或意外结果。我们会在运算符处理函数里妥善处理。
4.3 小数点输入处理input_decimal
小数点只能输入一次,并且如果当前显示是空或者处于“等待操作数”状态,应该显示“0.”。
def input_decimal(): global current_display, waiting_for_operand, last_input_type if waiting_for_operand: current_display = "0." waiting_for_operand = False else: if '.' not in current_display: current_display += '.' last_input_type = 'number' # 小数点输入视为数字输入的一部分4.4 运算符输入处理input_operator
这是状态管理的核心,也是最容易出错的地方。它需要处理连续运算、运算符变更等场景。
def input_operator(op): global operand_a, operand_b, current_operator, current_display, waiting_for_operand, last_input_type # 尝试将当前显示的内容转换为操作数 try: input_value = float(current_display) except ValueError: # 如果转换失败(比如空字符串或只有‘.’),按0处理或忽略 input_value = 0.0 # 情况1:这是第一次按运算符(之前没有未完成的运算) if current_operator is None: operand_a = input_value current_operator = op waiting_for_operand = True # 按了运算符,接下来等待输入第二个数 # 情况2:已经有未完成的运算(operand_a和current_operator已有值) else: # 如果上一次输入是数字(即我们刚刚输入了第二个操作数),那么需要先计算一次 if last_input_type == 'number' and not waiting_for_operand: operand_b = input_value # 执行计算 result = perform_calculation(operand_a, operand_b, current_operator) # 更新显示和状态:结果成为新的operand_a current_display = format_result(result) operand_a = result operand_b = 0.0 # 如果上一次输入就是运算符(连续按运算符),则只更新运算符,不计算 # 更新当前运算符 current_operator = op waiting_for_operand = True last_input_type = 'operator'这个逻辑确保了:
3 + 4 =能正确得到7。3 + 4 +:在输入第二个+时,由于last_input_type是数字(4),它会先计算3+4=7, 然后将7存入operand_a, 并将新的运算符+记录,等待下一个操作数。这符合大多数计算器的行为(立即执行)。3 + - *:在输入-时,由于按+后last_input_type是运算符,且waiting_for_operand为True, 所以不会触发计算,只是将current_operator从+更新为-。这允许用户更改尚未使用的运算符。
4.5 等号计算处理calculate_result
等号键触发最终计算。
def calculate_result(): global operand_a, operand_b, current_operator, current_display, waiting_for_operand, last_input_type # 如果没有运算符被设定,按等号什么都不做(或显示当前值) if current_operator is None: # 可选:可以将当前显示值赋给operand_a,为连续等号做准备 try: operand_a = float(current_display) except ValueError: pass return # 获取第二个操作数 try: # 如果正在等待操作数(比如刚按完运算符就按等号),则operand_b使用operand_a的值(实现如 3 + = 得6的功能) if waiting_for_operand: operand_b = operand_a else: operand_b = float(current_display) except ValueError: operand_b = 0.0 # 执行计算 result = perform_calculation(operand_a, operand_b, current_operator) # 更新显示 current_display = format_result(result) # 更新状态:结果为下一次运算的第一个操作数,运算符保持不变(用于连续按等号:3+2=5, =得7, =得9...) operand_a = result # operand_b 已被使用,可重置 # current_operator 保持不变! waiting_for_operand = True last_input_type = 'equals'这里有一个细节:if waiting_for_operand:这个分支实现了类似3 + =(结果为6)的功能,即缺少第二个操作数时,默认使用第一个操作数。
4.6 清空与退格功能
def clear_all(): """清空所有(C)""" global current_display, operand_a, operand_b, current_operator, waiting_for_operand, last_input_type current_display = "0" operand_a = 0.0 operand_b = 0.0 current_operator = None waiting_for_operand = True last_input_type = 'clear' def clear_entry(): """清空当前输入(CE)""" global current_display, waiting_for_operand current_display = "0" waiting_for_operand = True # 注意:CE不清除存储的操作数和运算符,只清除当前显示。4.7 实际计算与结果显示
perform_calculation函数执行具体的数学运算,并处理除零错误。
def perform_calculation(a, b, op): try: if op == '+': return a + b elif op == '-': return a - b elif op == '*': return a * b elif op == '/': if b == 0: return "Error" # 除零错误 return a / b else: return b # 如果没有运算符,返回第二个数(理论上不会发生) except Exception as e: return "Error" def format_result(value): """格式化结果,处理整数和小数显示""" if isinstance(value, str): # 如果是“Error” return value # 如果是整数,去掉末尾的.0 if value == int(value): return str(int(value)) else: # 保留一定精度,例如小数点后10位,然后去除末尾的0 return ('%.10f' % value).rstrip('0').rstrip('.')format_result函数让显示更友好,例如4.0显示为4,3.1415926535显示时去掉末尾多余的零。
5. 调试、优化与常见问题
5.1 界面美化与交互反馈
基础功能完成后,可以提升用户体验。比如,给按钮添加按下效果(改变颜色),或者在执行运算时让等号键闪烁一下。unihiker的按钮有config方法可以动态修改属性。
# 在on_button_click函数开头,可以添加一个视觉反馈 def on_button_click(value): btn = buttons.get(value) if btn: original_color = btn.cget('bg') # 假设有这个方法或属性,实际需查阅文档 btn.config(bg='#cccccc') # 按下时变灰色 gui.update() time.sleep(0.05) # 短暂延时 btn.config(bg=original_color) # 恢复原色 gui.update() # ... 原有的逻辑处理 ...注意:
unihiker的API可能不完全与标准tkinter一致,修改按钮颜色可能需要通过configure方法或特定属性实现,请参考其最新文档。如果实时反馈导致性能问题或卡顿,可以省略此步。
5.2 边界情况与Bug排查
在测试中,要特别注意以下场景,它们很容易出问题:
连续按等号:
2 + 3 =得5, 再按=应该得8(5+3), 再按得11(8+3)。我们的逻辑因为保留了current_operator和operand_a(上次结果),并且waiting_for_operand为True, 所以在calculate_result中会走operand_b = operand_a的分支,实现了重复加3的效果。测试乘除法同理。运算符连按:
3 + - * /。我们的逻辑是,当上一次输入是运算符时,连续按只会更新运算符,不进行计算。这符合多数计算器的“运算符优先”逻辑。你需要测试确保没有因此引发状态混乱。小数点开头:直接按
.应该显示0.。我们的input_decimal函数通过waiting_for_operand状态处理了这种情况。除零错误:任何数除以0应显示“Error”,并且错误状态应该阻止后续运算,直到按下
C清空。我们的perform_calculation返回 “Error”,format_result会直接显示。但需要在input_operator和calculate_result中检查current_display是否为 “Error”, 如果是,应阻止进一步计算,或自动执行清空。显示溢出:输入很长的数字怎么办?我们在
update_display和input_number中做了简单的长度限制。更优雅的做法是自动切换为科学计数法或缩小字体,但这会大大增加复杂度,V1版本可以先截断。
5.3 性能与内存考量
对于行空板这种资源有限的设备,虽然计算器应用很轻量,但良好的习惯很重要:
- 避免在频繁调用的函数(如
on_button_click)中创建大量临时对象。 update_display中先delete再draw_text是标准的Canvas更新方式,比频繁修改文本属性更可靠。- 确保没有意外的全局变量累积或循环引用。
5.4 部署与运行
代码完成后,在行空板上运行:
- 通过VSCode远程连接或使用官方软件,将
calculator_v1.py上传到行空板。 - 在行空板的终端中,进入文件所在目录,执行
python3 calculator_v1.py。 - 计算器界面应该会显示出来。现在你可以用触摸屏进行测试了。
如果遇到触摸不灵敏或按钮位置不准,回去检查按钮的坐标和大小计算,确保触摸区域 (w, h) 覆盖了视觉按钮,且彼此之间没有间隙过大(导致点不到)或重叠。
6. 总结与后续扩展方向
这个“计算器(一)”项目,从想法到实现,完整地走通了一个嵌入式图形化小应用的开发流程。我们不仅用unihiker画出了界面,更重要的是实现了一套严谨的状态机来处理计算逻辑。这其中的关键,是对waiting_for_operand、last_input_type等状态变量的精细管理,它们就像交通信号灯,指挥着数据流和运算流程。
在实际操作中,我最深的体会是测试一定要覆盖边界。不要只测1+1=2, 要多试试0.1+0.2(浮点数精度)、9 / 0、= = =连续按、+ - * /乱序按这些“刁钻”的操作。这些地方往往藏着逻辑漏洞。
这个V1版本是一个坚实的地基。基于它,未来可以轻松扩展出更多功能,这也是“(一)”的留白之处:
- (二)高级运算:加入平方根(
√)、百分号(%)、正负号(+/-)。 - (三)历史记录:在屏幕侧面或通过滑动展示最近的计算历史。
- (四)多主题切换:利用
unihiker更换按钮和背景颜色,实现夜间模式等。 - (五)语音反馈:利用行空板的扬声器,在按键时发出“嘀”声,或在报出结果(需要文本转语音库)。
编程的乐趣就在于,从一个简单的点子开始,像搭积木一样,看着功能一点点丰满起来。希望这个详细的拆解,能帮你不仅做出一个计算器,更能理解背后“状态驱动交互”的设计思想。下次当你使用手机上的计算器时,或许会对屏幕上每一次数字跳动背后的代码,会心一笑。