☰
Android Studio计算器源码解析:双栈引擎与28键布局
2026/10/24 5:03:20 网站建设 项目流程

简介:一份面向安卓初学者的计算器小程序源代码文档,基于Android Studio开发环境编写,主要用于解决新手在界面布局、按钮事件监听与基础运算逻辑上的练习需求。文档以docx格式封装,仅含1个文件,压缩后约34KB,核心内容包括网格布局的配置文件,其中定义5列8行的网格、表达式与输入输出两个文本框,以及清除、退格、内存等运算按键;代码部分则对应按钮点击、菜单选择、对话框取消等事件处理,并附带通过系统浏览器打开网页的扩展功能。已吸引1518人学习,适合作为移动开发课程作业或入门练手项目。通过这份文档,读者可以掌握计算器界面从控件排列到右对齐文本显示的搭建思路,理解程序运行中的界面交互与事件回调机制,并在此基础上自行完善科学计算、异常输入处理与多屏幕适配等高级特性。

1. 这个计算器项目到底给了你什么:28 键、双 TextView 与双栈引擎

用 Android Studio 做计算器的人很多,翻开源码却发现一大半只给了布局文件,算法要么写死在一长串 if-else 里,要么只能算一次就不能继续。这份“Android studio计算器”源码不太一样:它把 28 键的计算器拆成了清晰的界面层和表达式解析层,界面用 GridLayout 排 5 列 8 行,解析用经典的运算符栈 + 数字栈,输入与表达式各占一个 TextView,MC/MR/MS/M+/M- 五个内存键也有闭环。适合三类人:正在学 Android 事件监听的初学者,需要一份能讲明白原理的课程作业源码的人,想研究 Stack 和运算符优先级表配合方式的人。下文把它从布局到算法拆开讲,容易翻车的位置也会标出来。

2. 界面层拆解:GridLayout 布局与 28 个按钮的事件绑定

拿到一份 Android 项目源码,我习惯先看布局再看逻辑。这个项目的界面文件是一个根 GridLayout,里面塞了 2 个 TextView 和 28 个 Button,没有任何嵌套容器。好处是布局层级浅,渲染快,坏处是 GridLayout 的排列规则和重量分配不直观,抄代码时容易排错位置。这一章先把界面层讲透,下一章再进算法。

2.1 5 列 × 8 行的 GridLayout 是怎么排布的

根布局的关键参数只有四个:columnCount=5、rowCount=8、宽度match_parent、高度wrap_content。GridLayout 的排列规则很简单:子控件按在 XML 里的书写顺序,从左到右、从上到下自动填格子;不想按顺序排的,可以用layout_column、layout_row手动指定,但这个项目里除了跨行跨列的按钮,其余全靠顺序。

把控件按行展开,是这个结构:

行序号第 1 列第 2 列第 3 列第 4 列第 5 列
0expressionOutputView(跨 5 列)
1input_outputView(跨 5 列)
2MCMRMSM+M-
3DelCEC+/-sqrt
4789/%
5456*1/x
6123-=(rowSpan=2)
70(columnSpan=2).+← 等号延续至此

两个 TextView 通过layout_columnSpan="5"各占一整行,fill_parent的宽高配合layout_gravity="fill_horizontal|fill_vertical"才能撑满格子。这里有个细节:GridLayout 里直接写fill_parent不一定能铺满,必须让layout_gravity带上fill方向,否则控件会按照自身内容尺寸测量,看起来右边留白。

等号按钮的处理是底部排布的关键:它在第 6 行占据第 4 列,layout_rowSpan="2"让高度跨两行,再用layout_gravity="fill_vertical"把纵向填满。第 7 行剩下的位置由 0 和 . 、+ 填充,其中 0 占了columnSpan="2",于是底部恰好排满 5 列。这个“跨行 + 跨列”的组合是计算器布局里最容易抄错的地方,少写任何一个 span 或 gravity,整体就会错位。

2.2 两个 TextView:表达式框与输入输出框的分工

界面里两个 TextView 不是冗余,它们各自承担不同的显示职责。expressionOutputView在最上方,负责回显用户已经按过的完整表达式;它设了singleLine="true"和ellipsize="start",意味着文字超出宽度时,省略号出现在左边,保留右边最新输入的运算符和数字。

下方的input_outputView是当前输入值和计算结果的共用框,初始文本是 "0",maxLines="3"允许多行显示,同样是右对齐。这个双框设计在手机计算器上很常见,优点是用户在按长表达式时能同时看到“刚才按了什么”和“正在输入什么”。

两个框都是textAlignment="textEnd",也就是右对齐,这符合计算器显示习惯:数字从右往左增长,不会因为长度变化而跳动。字号统一用了 50sp,在模拟器上很合适,但真机窄屏上会放大溢出,这个坑后面避坑章节会专门讲。

2.3 一个监听器管 28 个按钮:内部类的设计

Java 端的按钮绑定没有写 28 行setOnClickListener,而是用了一个内部类统一处理:

public class MainActivity extends AppCompatActivity { private Button[] btn; private TextView ioView; private TextView expressionOutputView; private String expression = ""; private String input = "0"; private void initial() { btn = new Button[28]; ioView = (TextView) findViewById(R.id.input_outputView); expressionOutputView = (TextView) findViewById(R.id.expressionOutputView); ioView.setText("0"); btn[0] = (Button) findViewById(R.id.btn_0); // ... 依次绑定 28 个按钮 btn[27] = (Button) findViewById(R.id.btn_msub); MyOnClickHandler myOnClickHandler = new MyOnClickHandler(); for (Button i : btn) i.setOnClickListener(myOnClickHandler); } }

MyOnClickHandler是 MainActivity 的内部类,能直接读写外部类的expression和input成员变量。这样设计的好处很明显:28 个按钮的事件处理逻辑共享同一组状态,不需要在 Activity 和 Handler 之间来回传值;而且键盘上的每个按钮只是在同一个状态机上触发不同的动作,写成一个类更便于统一收口。

内部类里还声明了几个关键的工具方法:getRet负责根据运算符计算两个数的结果,someOfStr_in_anotherStr负责从指定位置开始找最近的运算符,calculate是表达式求值入口,stripTrailingZeros负责清理结果里多余的尾零。从命名上能看出这个作者是按“工具方法 + 核心算法”来组织的,而不是把所有逻辑堆在onClick里。

3. 双栈计算引擎:表达式求值里最关键的三件事

如果说布局是皮,那这个项目的计算引擎是骨。它没有用 Android 自带的表达式求值库,也没有用 JavaScript 引擎桥接,而是纯靠 Java 的Stack类实现了一个经典的中缀表达式求值器。核心代码不算长,但包含了三个必须理解清楚的点:双栈模型、优先级表的语义、负数识别分支。

3.1 运算符栈 + 数字栈:中缀表达式求值的基本模型

calculate()的第一步是建立两个栈:

private void calculate() throws Exception { Stack<Character> opStack = new Stack<>(); Stack<Double> numStack = new Stack<>(); opStack.add('#'); String tempExpression = expression + "#"; int start = 0; int end; while (start < tempExpression.length()) { end = someOfStr_in_anotherStr(operator, tempExpression, start); // 解析操作数,这里省略了负数分支 numStack.add(Double.parseDouble(tempExpression.substring(start, end))); // 比较运算符优先级,决定是压栈还是弹栈计算 // ... 循环内核心逻辑 } }

两个栈的分工很朴素:numStack存数字,opStack存运算符。表达式的处理方式是扫描字符串,每遇到一个运算符,就说明它前面夹着一段数字,把数字解析出来压进数字栈;然后再决定这个运算符是直接压栈,还是先把栈顶的运算符弹出来算一步。

为了处理边界,在栈底和表达式末尾各放了一个#哨兵字符。哨兵不进计算流程,只在两两相遇时表示表达式读完了,整个循环自然结束。这种方法比递归下降解析更容易读,也比“每次从头遍历找最高优先级运算符”的方式高效。

后半段循环原文没完整给出,但按这个结构,常见的补全逻辑是这样一段:

char nowOption = tempExpression.charAt(end); char topOption = opStack.peek(); int compareResult = compare[topOption][nowOption]; if (compareResult == 1) { opStack.add(nowOption); start = end + 1; } else if (compareResult <= 0) { double b = numStack.pop(); double a = numStack.pop(); char op = opStack.pop(); numStack.add(getRet(a, b, op)); // 注意:没有移动 start,继续处理当前运算符 }

这里的核心在于:compareResult == 1时当前运算符压栈并读下一个字符;compareResult <= 0时先弹栈计算,但start不前进,让循环继续拿同一个运算符和新栈顶比较。当两个#相遇返回 0,循环结束,numStack栈顶就是最终结果。

3.2 compare 矩阵:6×6 优先级表怎么读才是对的

优先级比较不是用一堆 if 判断,而是查一张 6×6 的表。运算符集合是+-*/%#,矩阵定义如下:

private int[][] compare = { {-1, -1, 1, 1, 1, -1}, // 栈顶为 + {-1, -1, 1, 1, 1, -1}, // 栈顶为 - {-1, -1, -1, -1, -1, -1}, // 栈顶为 * {-1, -1, -1, -1, -1, -1}, // 栈顶为 / {-1, -1, -1, -1, -1, -1}, // 栈顶为 % {1, 1, 1, 1, 1, 0} // 栈顶为 # };

行表示当前栈顶运算符,列表示当前读入的运算符。拿3 + 4 * 5举例:读入*时栈顶是+,查表compare[+][*] = 1,表示当前读入的*优先级更高,先把*压栈,等后面#到来时再逐层弹出,先算4 * 5再算3 + 20。

这里有个非常值得注意的细节:源码注释写的是“-1 代表低于,1 代表高于”,字面上容易理解成“返回值表示栈顶运算符的优先级关系”。但按实际运行行为反推,1的实际动作是“当前运算符压栈”,-1的实际动作是“弹出栈顶参与计算”。也就是说表是站在当前运算符的角度描述的。如果照注释直译一个优先级比较器,整个计算顺序会被带偏,2 + 3 * 4会算出错误结果。

同优先级运算符之间取-1,比如+遇+、+遇-,都会先弹栈计算,这正好实现从左到右的结合方向。只有栈顶和当前都是#时返回0,表示表达式扫描结束。这张表是理解整个计算器行为的关键,建议到手源码后先在纸上手推三四个表达式,再对照行为理解注释,能省不少绕弯的功夫。

3.3 负数识别:end - start < 1 分支到底在判断什么

扫描表达式时,someOfStr_in_anotherStr找到的end是最近一个运算符的下标,start到end之间应该是数字。但负数会使这个逻辑出现边界情况,比如-5 + 3一开头就是一个负号,这时end - start < 1,两个运算符之间没有数字。源码的处理是:

if (end - start < 1) { if (tempExpression.charAt(end) == '-') { start = end; end = someOfStr_in_anotherStr(operator, tempExpression, start + 1); } else { throw new Exception(ExpressionError); } }

这段代码解决的是两个问题:一是一元负号,二是连续运算符场景,比如3 * -4 + 5。扫描到*之后,下一次从*后面找运算符,找到的是-,此时end - start为 0,发现紧邻的是-,就把它当作数字的符号,重新从-的下一个位置找真正的运算符,最终截取-4解析为数字。

这个分支只放行-,遇到3 * +4这类一元正号会直接抛“表达式错误”。这是设计取舍:不支持一元正号,但支持一元负号。实际使用中够用,因为正常用户很少在运算中间输入+4这种写法。

3.4 一元运算怎么落:sqrt、1/x、+/- 的处理思路

布局里有sqrt、1/x、+/-三个一元运算键,它们的处理方式和二元运算符完全不同:二元运算符要进表达式参与解析,一元运算更像是对“当前输入值”的即时变换。源码里calculate()只负责四则运算加取模,一元运算应该在onClick里走独立分支。

常见做法是这样:点击+/-时,把input取出转成 double,取负后再写回文本框;点击1/x时判断当前值是否为 0,不为 0 则求倒数并写回;点击sqrt时先判断负数,负数弹错误提示,非负数开方后写回。

private void handleUnary(char op) { double cur = Double.parseDouble(input); switch (op) { case 's': if (cur < 0) throw new IllegalArgumentException(SqrtByNegative); input = stripTrailingZeros(String.valueOf(Math.sqrt(cur))); break; case 'r': if (cur == 0) throw new ArithmeticException(DivByZeroError); input = stripTrailingZeros(String.valueOf(1.0 / cur)); break; case 'n': input = stripTrailingZeros(String.valueOf(-cur)); break; } ioView.setText(input); }

这里容易忽略的是精度显示。Math.sqrt(2)算出来是1.4142135623730951,如果不做尾零清理,sqrt(4)会显示2.0而不是2。源码里出现的stripTrailingZeros就是为了处理这个问题,它把 double 转成字符串后去除末尾无意义的 0,让显示更干净。

一元运算执行后,通常还要把本次操作追加到表达式框,比如表达式框显示√(9),输出框显示3。这样用户能看到完整的操作历史,而不是莫名其妙蹦出一个数。

4. 内存键、菜单与对话框:把计算器从“能算”做到“能维护”

四则运算能跑通只是第一步。这份源码相比很多教学 demo 多了一排内存键 MC/MR/MS/M+/M-,还带了菜单和关于对话框的骨架。这一章讲功能闭环,也顺便指出代码里几个需要注意的细节。

4.1 saveNum 与 MC/MR/MS/M+/M- 的语义闭环

内存键的变量是内部类里的private double saveNum = 0.0,初始值为 0。五个键的语义按标准计算器定义如下:

按键语义对应操作
MC清除内存saveNum = 0.0
MR读取内存把saveNum显示到输入输出框
MS保存到内存用当前输入值覆盖saveNum
M+累加进内存saveNum += 当前输入值
M-从内存减去saveNum -= 当前输入值

按这份源码 28 键的下标映射,btn[23]到btn[27]依次是 MC、MR、MS、M+、M-。常见的处理写法是这样:

private void handleMemoryKey(int index) { double cur = Double.parseDouble(input); switch (index) { case 23: // MC saveNum = 0.0; break; case 24: // MR input = stripTrailingZeros(String.valueOf(saveNum)); ioView.setText(input); break; case 25: // MS saveNum = cur; break; case 26: // M+ saveNum += cur; break; case 27: // M- saveNum -= cur; break; } }

这里有个浮点上的老问题:saveNum是 double,连续几次 M+ 操作后,显示值可能带上浮点尾数。比如 0.1 加三次,saveNum内部是0.30000000000000004,MR 显示出来就露馅。所以 MR 读取时一定要走stripTrailingZeros或 BigDecimal 格式化,不能直接String.valueOf(saveNum)。双击 M+ 或连续 M+ 的场景,是测内存功能最容易暴露问题的地方。

4.2 一处注释与实现不符的按钮初始化

源码里初始化部分有一段:

btn[23].setClickable(false);//M+ btn[24].setClickable(false);//M-

按钮下标映射在前文已经列过:btn[23]是btn_mc,btn[24]是btn_mr。也就是说这段代码实际禁用的是 MC 和 MR 键,但注释写的是禁用 M+ 和 M-。这是明显的复制粘贴问题。

从业务语义讲,初始状态只有 MS 是明确可用的,因为内存里还没有任何值,MC(清内存)和 MR(读内存)没有意义,M+/M- 也没有累加基础。所以更合理的初始化应该是禁用btn[23]、btn[24]、btn[26]、btn[27],只放行btn[25](MS)。等到用户第一次执行 MS 或 M+/M- 之后,再动态把 MC/MR 置为可点。如果只是照抄源码,运行后会发现 MR、MC 是灰的,而 M+、M- 能点,这和你预期的内存闭环是反的。具体修法放在避坑章节。

4.3 菜单与关于对话框:onOptionsItemSelected 的扩展点

源码里还留了一段onOptionsItemSelected,用Menu.FIRST系列常量区分菜单项,其中一个分支弹出一个“关于”对话框,确认后跳转外部页面,另一个分支把某个名为isPaused的字段设为 false。从字段命名看,这段逻辑大概率是从别的项目移植过来的,在这个计算器上下文里没有对应的 view 对象。

菜单的编程方式用的是旧式的Menu.FIRST + n索引,而不是菜单资源 ID。这种方式在现在的新项目里不推荐,但它展示了一个扩展点:如果要在计算器里加“帮助”“关于”“换肤”等功能,在onOptionsItemSelected里增加分支就可以。把这段代码接到界面上,需要先在 Activity 里重写onCreateOptionsMenu,给菜单添加三个条目,分别对应Menu.FIRST、Menu.FIRST + 1、Menu.FIRST + 2。Menu.FIRST + 2那段把isPaused置 false,逻辑上是“恢复”,对应的Menu.FIRST + 1预计是“暂停”,但源码没把前面的分支写全。

这个代码块的实用价值是演示了AlertDialog的链式写法:setTitle、setMessage、setPositiveButton依次设置,最后create().show()。对话框通过setCanceledOnTouchOutside(true)允许点击外部关闭,这是移动端对话框很常见的用户习惯,值得保留。

5. 常见问题与避坑:跑通这份源码最容易翻车的 5 个地方

部署一份别人的源码,最花时间的不是读代码,而是踩坑。这里把我在跑这个项目过程中真实遇到的几个问题按“现象 → 原因 → 解决”写出来。

5.1 现象:MC、MR 初始变灰,M+、M- 反而能点

运行后界面上 MC、MR 两个键是灰色不可点的,M+、M- 却能正常点击,和预期完全相反。查看代码发现initial()里写了btn[23].setClickable(false)和btn[24].setClickable(false),注释却写着 M+ 和 M-。

原因就是按钮下标映射和注释对不上,btn[23]是btn_mc,btn[24]是btn_mr,作者想禁用 M+/M- 却写错了下标。解决方法是按下标映射修正,同时考虑内存键的完整状态逻辑:

btn[23].setClickable(false); // MC btn[24].setClickable(false); // MR btn[26].setClickable(false); // M+ btn[27].setClickable(false); // M- // btn[25] MS 保持可用,作为内存功能的入口

如果只是想让初始状态符合直觉,这一处修正就够了。更严谨的做法是在每次内存操作后同步更新按钮可用状态:执行过 MS 或 M+/M- 后,把 MC、MR、M+/M- 全部置为可点。

5.2 现象:3*+4 报“表达式错误”,3*-4 却正常

输入3*+4再按等号,直接弹出“表达式错误!”,但同样结构换成3*-4就能算出 -12。原因是calculate()的负数识别分支只放行-,遇到+、*、/等连续运算符会认为表达式不合法,抛出异常。

从设计角度讲这是可接受的行为,因为“一元正号”在数学上等价于省略,3*+4完全可以写成3*4。真正需要注意的是:如果你改代码支持一元正号,必须在if (tempExpression.charAt(end) == '-')的判断里把+也放进去,并且把start移到end + 1,否则+4里的 4 会丢失。这个分支很微妙,建议改完后把3*+4、3*-4、-5+3、3--4四组用例全部跑一遍。

5.3 现象:5.5 % 2 被拒绝,提示“不能对小数或负数进行取余数操作!”

取模键%是布局里的特色功能,但随便拿小数试一下就报错。原因在getRet里的取模分支:

if ((a != (int) a || b != (int) b) || (a < 0 || b < 0)) throw new Exception(ModByInt); ret = a - ((int) (a / b)) * b;

作者故意把取模限制在“非负整数”范围内。原因是 Java 的%运算符对 double 的操作结果是浮点余数,对负数的处理又遵循向零取整规则,容易让初学者困惑。这里直接限制输入类型,把边界问题通过异常挡在门外。

如果要用原生 Java 取模,直接把ret = a % b即可,但要注意结果可能带小数尾数,比如5.5 % 2的结果是1.5。如果产品上允许小数取模,建议用BigDecimal.remainder()而不是 double 的%,能避免大部分精度问题。否则就要像源码一样,在 UI 上给用户明确提示,而不是抛一个笼统的异常文本。

5.4 现象:0.1+0.2 显示一长串浮点尾数

输入0.1+0.2=,输出框显示0.30000000000000004,虽然数学上不算错,但用户观感极差。原因不难猜:表达式里的数字都用Double.parseDouble解析,计算结果也直接用 double 参与运算,而 IEEE 754 浮点数无法精确表示 0.1 和 0.2。

源码里有一个stripTrailingZeros方法,它的作用是清理 double 转字符串后末尾的 0,比如让2.0显示成2。但它解决不了0.30000000000000004这种尾数问题,因为尾数不是 0。常见做法有两种:一是用BigDecimal.valueOf(ret).stripTrailingZeros().toPlainString()再转回字符串显示;二是限制小数位数,保留精度到一定位数后四舍五入。

需要注意BigDecimal的构造方法,用new BigDecimal(0.1)会把 double 的二进制近似值原样带进来,越转越乱,得用BigDecimal.valueOf(0.1)或字符串构造。这个项目如果只是交作业,用String.format("%.10f", ret)再清尾零是最省事的方案。

5.5 现象:50sp 大字号在窄屏上溢出,表达式框从头部省略

模拟器上界面显示良好,换到小屏真机,数字一长就会溢出到边界。原因有两个:input_outputView的textSize="50sp"是固定值,不随屏幕宽度缩放;expressionOutputView的ellipsize="start"在超长时会吞掉表达式开头的字符。

50sp 在 1080p 模拟器上能显示约 8 个字符,在 720p 屏上只能显示约 5 个,用户按一个较长的表达式根本看不全。解决思路有两种:一是改用 AndroidX 的 AutoSizeTextView,设置最小文本大小和最大文本大小,让字号随控件宽度自适应;二是在代码里动态计算,把 dp 转成 px 后按屏幕宽度等比缩放。

DisplayMetrics dm = getResources().getDisplayMetrics(); float textSize = Math.min(50f, dm.widthPixels / 20f); ioView.setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize);

表达式框的头部省略其实是可以接受的,因为用户关心的是最近输入的内容,最左边是已经确认过的运算符和数字。但如果你希望用户能回看完整表达式,可以把ellipsize改为marquee并开启滚动,或者把singleLine改成maxLines="2"换行显示。

6. 验证与进阶:用一张自测表把这套源码跑服

代码能编译不等于能交付。计算器这种工具类应用,边界情况多到足以让你怀疑人生。我的习惯是拿到任何计算器 demo,先建一张冒烟测试表,把常规运算、异常场景、内存功能分开跑一遍,跑过再谈优化。

编号输入操作期望结果实际关注点
11+2=3基本加法
22*3+4=10乘法优先级高于加法
33+4*2=11反序验证优先级不依赖输入顺序
49/0=除数不能为零!除零异常拦截
59%2=1整数取模
6-5+3=-2负数开头
73*-4=-12运算符后紧跟负数
87 按 +/--7正负号切换
99 按 sqrt3开方且无尾零
1010 按 1/x0.1倒数
1112 按 M+,再按 3 M+,MR15内存累加
120.1+0.2=0.3浮点显示是否干净
131÷3=0.3333333333结果位数是否可接受

其中第 12 和第 13 条是浮点相关的硬关卡。很多计算器 demo 在整数运算里一切正常,一到小数就露馅。如果你拿到手的是原始 double 实现,建议先跑这两条,不合格就按前面 5.4 的 BigDecimal 方案改造。

验证环境上,模拟器和真机有差异。模拟器普遍分辨率高、密度大,50sp 的文字看起来刚好;真机如果是小屏,同样 sp 会显得偏大。所以布局相关的几条一定要在真机上过一遍,尤其是底部按钮宽度 80dp 是否会被系统缩放。还有等号按钮的rowSpan=2,在部分国产 ROM 上可能因为 GridLayout 版本差异出现高度测量不准,这时可以手动给等号按钮设一个最小高度兜底。

进阶方向上,这套双栈引擎最值得改的是支持括号。当前compare表只覆盖+-*/%#六种字符,要加括号得扩成 8×8 矩阵:读入(时无条件压栈,读入)时不断弹栈计算直到弹出匹配的(。改的时候注意,)之后的数字解析逻辑要跟着变,否则(1+2)*3这类表达式会扫描出“括号后紧跟数字”的异常情况。

另一个方向是把 double 换成 BigDecimal。改动面不小:Stack<Double>要变Stack<BigDecimal>,getRet的四则运算全部换成 BigDecimal 方法,解析数字的Double.parseDouble对应换成new BigDecimal(String)。但换来的是浮点问题彻底消失,加法和乘法都能精确落笔。

我第一次拿这套源码跑真机时,就是按着这张表敲到第 12 条,在 0.1+0.2 上被一长串浮点尾数糊了一脸,才意识到“能算”和“能交付”之间差着多少边界处理。从那以后我拿到任何计算器 demo,第一件事都是先过一遍小数精度和内存键状态,这两关没过就坚决不上线。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询