毫无疑问,从幼年开始的好习惯是最完美的,我们把这叫做‘教育’,因为教育其实就是一种早年开始的习惯。所以我们看到与以后的时期相比,幼年时代学语言,舌头学习表达方式和发音时更柔顺,学各种技巧动作时,关节更灵活。——培根

源码地址:

https://gitee.com/VampireAchao/honor-calc-automation

Honor Calc Automation:Android 无障碍自动化计算器开发全流程详解

项目简介

Honor Calc Automation 是一个基于 Kotlin 的 Android 自动化工具,利用无障碍服务(AccessibilityService)自动操作荣耀计算器,实现批量、自动化输入和计算数学公式。项目结构清晰,界面简洁,支持日夜间主题,适合学习 Android 无障碍自动化开发。


技术栈与项目结构

  • 开发语言:Kotlin(核心),部分 Java
  • 构建工具:Gradle
  • UI 框架:Material Components
  • 主要模块
    • MainActivity.kt:主界面与用户交互
    • MyAccessibilityService.kt:无障碍自动化服务
    • res/:布局与主题资源

主要功能

  1. 批量自动计算:用户输入公式,应用自动调用荣耀计算器并模拟按钮输入,完成计算。
  2. 无障碍服务自动操作:通过 AccessibilityService 实现对第三方应用(计算器)的自动化控制。
  3. 主题适配:支持日夜间模式,提升视觉体验。
  4. 权限引导:自动检测无障碍权限,便捷引导用户开启。

开发流程与调试技巧

1. 如何确定不同手机的计算器包名

在自动化开发中,不同品牌手机的计算器包名可能不同。推荐用 logcat 快速筛选:

  • 步骤
    1. 在 Android Studio 的 logcat 面板,输入 cmp=Starting: 作为过滤条件。

    2. 在手机上手动打开计算器应用,观察 logcat 输出,通常会出现如下日志:

      1
      Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.hihonor.calculator/.Calculator }

      其中 cmp= 后面的 com.hihonor.calculator 就是包名。

    3. 也可以用 adb 命令辅助:

      1
      adb shell "dumpsys window | grep mCurrentFocus"

      切换到计算器界面,命令输出会包含当前前台应用的包名。

2. 节点结构分析与调试

开发无障碍自动化时,首先要了解目标应用界面控件结构。项目中实现了递归遍历所有节点,打印详细信息和 actions 的调试代码:

  • 作用:输出当前界面所有控件(节点)的类名、文本、资源ID、描述、可编辑性、子节点数和可用操作,便于分析每个按钮的属性。
  • 开发流程
    1. 先实现递归遍历并打印所有节点信息。
    2. 运行服务,在目标界面(如计算器)触发无障碍事件,查看日志输出。
    3. 分析日志,查找每个按钮(如数字、运算符、等号)对应的 textresourceId 等属性。
    4. 根据日志信息,选择合适的查找方式(如通过 textresourceId)。
    5. 用分析得到的节点信息,编写代码自动查找并点击对应按钮,实现自动化输入。

这种方式极大提高了无障碍自动化开发的准确性和效率,避免了盲目猜测控件属性。


关键代码逻辑详解

1. 主界面交互(MainActivity.kt

  • 公式输入与提交:用户在输入框填写公式,点击“计算”按钮后,调用无障碍服务的静态方法 enqueueFormula 提交公式。
  • 无障碍权限检测:如未开启无障碍服务,点击“开启无障碍”按钮跳转系统设置。
  • 用户反馈:通过 Toast 显示提交状态或错误提示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.calculatorautomation

import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

val formulaInput = findViewById<EditText>(R.id.formulaInput)
val calcBtn = findViewById<Button>(R.id.calcBtn)
val openAccessibilityBtn = findViewById<Button>(R.id.openAccessibilityBtn)

calcBtn.setOnClickListener {
val formula = formulaInput.text.toString()
if (formula.isNotBlank()) {
MyAccessibilityService.enqueueFormula(formula)
Toast.makeText(this, "已提交公式: $formula", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "请输入公式", Toast.LENGTH_SHORT).show()
}
}

openAccessibilityBtn.setOnClickListener {
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
startActivity(intent)
}
}
}

2. 无障碍自动化服务(MyAccessibilityService.kt

2.1 服务初始化与事件监听

  • 单例模式:通过 companion object 保证服务唯一实例,并存储待处理公式。
  • 服务连接onServiceConnected 设置监听窗口变化和点击事件,确保只在计算器界面处理公式。

2.2 公式处理流程

  • 公式队列enqueueFormula 静态方法接收公式,触发处理。
  • 自动启动计算器launchCalculator 方法通过显式 Intent 启动荣耀计算器。
  • 窗口变化监听onAccessibilityEvent 检测到计算器界面时自动处理公式。

2.3 节点遍历与按钮点击

  • 递归遍历节点并打印logAllNodes 方法递归遍历所有节点,输出详细信息,辅助开发调试。
  • 输入公式inputFormula 遍历界面节点,查找并点击对应数字、运算符按钮,最后点击等号。
  • 节点查找findNodeByResId 递归查找指定资源 ID 的节点,确保兼容不同界面结构。
  • 日志调试:详细日志输出,便于调试和问题定位。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package com.example.calculatorautomation

import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo

class MyAccessibilityService : AccessibilityService() {
companion object {
private var instance: MyAccessibilityService? = null
private var pendingFormula: String? = null

fun enqueueFormula(formula: String) {
Log.d("CalcDemo", "enqueueFormula: $formula")
pendingFormula = formula
instance?.processFormula()
}
}

override fun onCreate() {
super.onCreate()
Log.d("CalcDemo", "MyAccessibilityService onCreate")
}

override fun onServiceConnected() {
super.onServiceConnected()
instance = this
Log.d("CalcDemo", "无障碍服务已连接")
serviceInfo = serviceInfo.apply {
eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or AccessibilityEvent.TYPE_VIEW_CLICKED
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
flags = AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS
}
}

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
Log.d("CalcDemo", "onAccessibilityEvent: $event")
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val pkg = event.packageName?.toString()
Log.d("CalcDemo", "窗口变化: $pkg")
if (pkg?.contains("calculator", ignoreCase = true) == true ||
pkg?.contains("honor", ignoreCase = true) == true) {
processFormula()
}
}
}

override fun onInterrupt() {
Log.d("CalcDemo", "无障碍服务被中断")
}

private fun launchCalculator() {
try {
val intent = Intent()
intent.setClassName("com.hihonor.calculator", "com.hihonor.calculator.Calculator")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
Log.d("CalcDemo", "已尝试启动荣耀计算器")
} catch (e: Exception) {
Log.e("CalcDemo", "启动计算器失败: ${e.message}")
}
}

fun processFormula() {
val formula = pendingFormula ?: return
pendingFormula = null
Log.d("CalcDemo", "开始处理公式: $formula")
launchCalculator()
Handler(Looper.getMainLooper()).postDelayed({
inputFormula(formula)
}, 1200)
}

private fun inputFormula(formula: String) {
val root = rootInActiveWindow
if (root == null) {
Log.e("CalcDemo", "无法获取当前窗口节点")
return
}
Log.d("CalcDemo", "开始输入公式")

// 递归遍历所有节点,打印详细信息和actions
fun logAllNodes(node: AccessibilityNodeInfo?, depth: Int = 0) {
if (node == null) return
val prefix = " ".repeat(depth)
val cls = node.className?.toString() ?: ""
val txt = node.text?.let { "\"$it\"" } ?: "null"
val resId = node.viewIdResourceName ?: ""
val desc = node.contentDescription?.toString() ?: ""
val actions = node.actionList?.joinToString { it.label?.toString() ?: it.id.toString() } ?: ""
Log.d(
"CalcDemo",
"${prefix}Node: class=$cls, text=$txt, resId=$resId, desc=$desc, editable=${node.isEditable}, childCount=${node.childCount}, actions=[$actions]"
)
for (i in 0 until node.childCount) {
logAllNodes(node.getChild(i), depth + 1)
}
}
logAllNodes(root)

// 只做按钮点击,不做结果验证
for (ch in formula) {
val btnNode = when (ch) {
'0','1','2','3','4','5','6','7','8','9','.' -> {
root.findAccessibilityNodeInfosByText(ch.toString()).firstOrNull {
it.className?.toString()?.contains("Button") == true
}
}
'+' -> findNodeByResId(root, "com.hihonor.calculator:id/op_add")
'-' -> findNodeByResId(root, "com.hihonor.calculator:id/op_sub")
'*', 'x', 'X' -> findNodeByResId(root, "com.hihonor.calculator:id/op_mul")
'/' -> findNodeByResId(root, "com.hihonor.calculator:id/op_div")
else -> null
}
if (btnNode != null) {
btnNode.performAction(AccessibilityNodeInfo.ACTION_CLICK)
Thread.sleep(200)
Log.d("CalcDemo", "点击按钮: $ch")
} else {
Log.e("CalcDemo", "未找到按钮: $ch")
}
}
// 点击等号
val eqNode = findNodeByResId(root, "com.hihonor.calculator:id/eq")
if (eqNode != null) {
eqNode.performAction(AccessibilityNodeInfo.ACTION_CLICK)
Log.d("CalcDemo", "点击等号")
} else {
Log.e("CalcDemo", "未找到等号按钮")
}
}

private fun findNodeByResId(node: AccessibilityNodeInfo, resId: String): AccessibilityNodeInfo? {
if (node.viewIdResourceName == resId) return node
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
val result = findNodeByResId(child, resId)
if (result != null) return result
}
return null
}
}

主题适配与资源管理

  • 日夜间主题:通过 values/themes.xmlvalues-night/themes.xml 分别定义日间与夜间配色,自动适配系统模式。
  • 布局资源activity_main.xml 提供输入框、按钮等基础交互组件。

部署与体验

  1. 克隆仓库
    git clone git@gitee.com:VampireAchao/honor-calc-automation.git
  2. 用 Android Studio 打开项目
  3. 运行应用,按提示开启无障碍服务
  4. 输入公式,点击“计算”体验自动化功能

总结与亮点

  • 自动化与无障碍结合:通过 AccessibilityService 实现对第三方应用的自动操作,适合批量计算等场景。
  • 开发流程科学:先用 logcat 确定包名,再递归打印节点,分析日志,确定控件属性,再实现自动点击,极大提升开发效率和准确性。
  • 代码结构清晰:主界面与服务解耦,易于扩展和维护。
  • 丰富日志与调试支持:便于开发者定位问题。
  • 适合学习与二次开发:可作为 Android 无障碍自动化的入门项目。

欢迎感兴趣的开发者参与贡献或提出建议!