仿 fooView 悬浮球拖拽识别屏幕文字

效果展示


方案概述

方案实现的大致步骤如下:

  1. 注册辅助功能服务,通过辅助功能获取屏幕内容
  2. 筛选屏幕中的文字内容,并记录每一块文字在屏幕中的位置
  3. 显示悬浮球,实现其拖拽逻辑
  4. 根据悬浮球拖拽的坐标,匹配第 2 步中记录的文字位置
  5. 显示一个新的浮层,根据上一步中匹配到的文字位置,在这个浮层对应区域绘制文字的边框

上述方案需要的权限有两个:

  1. 辅助功能权限(需要向系统注册功能功能服务)
  2. 悬浮窗权限(在其他应用上层显示的权限)

代码实现

注册辅助功能

AndroidManifest.xml 中声明辅助功能服务。

1
2
3
4
5
6
7
8
9
10
11
12
<service
android:name=".AssistService"
android:label="@string/app_name"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>

<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/assist_service" />
</service>

其中 @xml/assist_service 是辅助功能的配置文件,我们在 res/xml 下创建这个配置文件 assist_service.xml。这个文件定义了我们需要的接收的服务功能事件类型,是否需要获取屏幕内容,是否需要模拟点击操作等。

1
2
3
4
5
6
7
8
<!-- assist_service.xml -->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackAllMask"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews|flagReportViewIds"
android:canRetrieveWindowContent="true"
android:description="@string/assist_desc"
android:notificationTimeout="100" />

最后我们需要创建 AssistService,也就是一开始在 AndroidManifest.xml 中声明的服务,它继承于 AccessibilityService,从而使我们可以接收系统分发的辅助功能事件,以及调用辅助功能相关的 api。

1
2
3
4
5
class AssistService : AccessibilityService() {
override fun onAccessibilityEvent(event: AccessibilityEvent) {
// 处理系统传递来的辅助功能事件
}
}

至此,辅助功能创建完成,app 启动后,我们可以在系统设置的辅助功能选项中,找到我们 app 注册的辅助功能,手动开启后,Android 系统会自动为我们启动 AssistService

通过辅助功能获取屏幕上的文字内容

通过 AssistServicegetRootInActiveWindow() 方法,可以获取屏幕内容的根节点,从而遍历获取包含文字内容的节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fun parseNodes(): MutableList<AccessibilityNodeInfo> {
val rootNode = mService.rootInActiveWindow
val result: MutableList<AccessibilityNodeInfo> = ArrayList()
iterateNodes(rootNode, result)
return result
}

private fun iterateNodes(nodeInfo: AccessibilityNodeInfo, result: MutableList<AccessibilityNodeInfo>) {
for (i in 0 until nodeInfo.childCount) {
nodeInfo.getChild(i)?.let {
if (!TextUtils.isEmpty(it.text)) {
result.add(it)
}
iterateNodes(it, result)
}
}
}

至此,我们将所有文字内容不为空的节点全部筛选了出来。

显示悬浮球

悬浮球的显示与拖拽功能的实现直接使用了一个开源库 EasyFloat,这里不再赘述。细节可以参考 EasyFloat 的 Readme,或文末贴出的源码地址。

通过 EasyFloat 我们显示了一个可以拖拽的悬浮球,并能通过回调接口等待悬浮球拖拽的坐标。

根据悬浮球坐标匹配对应位置的文字节点

我们获取文字节点在屏幕上的坐标,与悬浮球拖拽的坐标进行对比,从而找到与悬浮球拖拽点重叠的文字节点。

1
2
3
4
5
6
7
8
9
10
11
// point 为悬浮球的拖拽坐标
private fun captureNode(point: Point): AccessibilityNodeInfo? {
for (node in allTextNodes) {
val rect = Rect()
node.getBoundsInScreen(rect)
if (rect.contains(point.x, point.y)) {
return node
}
}
return null
}

绘制文字节点高亮框

首先我们需要再显示一个悬浮窗,这个悬浮窗大小为整个屏幕的大小,背景透明,我们根据上一步中匹配到的文字节点的位置,在这个悬浮窗上的相同位置绘制一个高亮的框体,从而在视觉上呈现一种文字被框选的效果。

1
2
3
4
5
6
7
8
9
paint.style = Paint.Style.STROKE
paint.color = Color.RED
paint.strokeWidth = 2F
...
override fun onDraw(canvas: Canvas) {
val bounds = Rect()
node.getBoundsInScreen(bounds)
canvas.drawRect(bounds, paint)
}

至此,我们基本实现了 效果展示 中的效果,文字的内容可以通过 node.getText() 得到。但在实际使用中会渐渐发现一些新的问题,下文会一一列举这些问题与解决方案。

问题 1: 文字节点重合怎么办

问题描述: 在某些极端情况下,屏幕上显示的两块文字有可能部分重叠,如果拖拽坐标恰巧在重叠的区域,我们应该选择哪一个文字作为被选中的文字呢?

解决方案: 优化文字匹配算法。优化的思路有很多,比如在匹配到多个文字 node 时,取最小的一个作为被选中的文字 node。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private fun captureNode(point: Point): AccessibilityNodeInfo? {
var targetNode: AccessibilityNodeInfo? = null
for (item in mNodeInfos) {
val itemRect = Rect()
item.getBoundsInScreen(itemRect)
// 匹配到一个文字 node
if (itemRect.contains(point.x, point.y)) {
// 如果之前已经匹配到一个文字 node,则对比两个 node 大小,取更小的一个
if (targetNode != null) {
val targetRect = Rect()
targetNode.getBoundsInScreen(targetRect)
if (itemRect.width() < targetRect.width() && itemRect.height() < targetRect.height()) {
targetNode = item
}
} else {
targetNode = item
}
}
}
return targetNode
}

问题 2: 微信文本消息无法识别

问题描述: 微信的文本消息使用自定义控件绘制,通过 node.getText() 得到内容永远为 null。

解决方案: 双击微信文本消息后,会跳转到一个单独的展示文字内容的页面,在这个页面我们可以通过 node.getText() 获取到文字内容。因此我们可以通过这种方式来获取到微信文本的消息。

仔细观察 效果展示 中的细节,会发现识别微信文本内容时,有一个界面一闪而过,它就是我们刚刚提到的微信单独展示文字内容的界面。

上述解决方案是通过观察 fooview 识别微信文本内容的效果猜到的,识别微信文本 node 的判断方法也是通过反编译 fooview 得到的。实际上这边文章编写的初衷也是希望实现类似 fooview 的文字识别功能,如果没有 fooview,也就不会有这篇文章了。

具体来说,识别微信文本内容分为 3 步:

  1. 识别微信文本节点,并将该类型的节点也从屏幕所有节点中筛选出来
  2. 当拖拽到微信文本节点时,模拟双击微信文本
  3. 监听屏幕内容变化的 event,当发现跳转到微信的文字详情页时,获取该页面的文字内容

通过反编译 fooview,可以找到一个用于判断微信文本 node 的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun isWechatMsgNode(node: AccessibilityNodeInfo): Boolean {
val charSequence =
if (node.contentDescription != null) {
node.contentDescription.toString()
} else {
null
}
val packageName = node.packageName
val className = node.className
return !TextUtils.isEmpty(packageName) && packageName.toString() == "com.tencent.mm" &&
!TextUtils.isEmpty(className) && className.toString() == View::class.java.canonicalName &&
TextUtils.isEmpty(charSequence) && node.isClickable &&
node.isLongClickable && node.isEnabled &&
!TextUtils.isEmpty(node.viewIdResourceName)
}

我们需要修改 通过辅助功能获取屏幕上的文字内容 的方法,将微信文本 node 也加入到筛选结果中。

1
2
3
4
5
6
7
8
9
10
private fun iterateNodes(nodeInfo: AccessibilityNodeInfo, result: MutableList<AccessibilityNodeInfo>) {
for (i in 0 until nodeInfo.childCount) {
nodeInfo.getChild(i)?.let {
if (!TextUtils.isEmpty(it.text) || isWechatMsgNode(it)) {
result.add(it)
}
iterateNodes(it, result)
}
}
}

当拖拽到微信文本 node 时,我们需要调用辅助功能服务的 api 进行模拟点击。该 api 仅支持 24 及以上,并且使用该 api 需要在 assist_service.xml 中声明使用权限,因此我们需要再单独添加一个 xml-v24 下的 assist_service.xml 文件。相比于原来的 xml/assist_service.xml,它只多了一行属性 android:canPerformGestures="true"

调用模拟点击 api 的示例代码如下。

1
2
3
4
5
6
7
8
@RequiresApi(Build.VERSION_CODES.N)
fun simulateClick(service: AccessibilityService, point: Point) {
val clickPath = Path()
clickPath.moveTo(point.x.toFloat(), point.y.toFloat())
val strokeDescription = StrokeDescription(clickPath, 0, 10L)
val gesture = GestureDescription.Builder().addStroke(strokeDescription).build()
val result = service.dispatchGesture(gesture, null, null)
}

有了模拟点击的 api,我们就可以模拟双击微信文本 node,从而跳转到微信文本详情页。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (isWechatMsgNode(selectedNode)) {
val rect = Rect()
selectedNode.getBoundsInScreen(rect)
// 获取微信文本 node 的中心点
val point = Point(rect.centerX(), rect.centerY())

// 模拟双击中心点,以打开文本详情页面
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
mMainHandler.postDelayed(Runnable {
simulateClick(mService, point)
}, 100)
mMainHandler.postDelayed(Runnable {
simulateClick(mService, point)
}, 200)
}
return
}

最后,我们监听文本详情页面显示的 event,从而获取到文字内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
override fun onAccessibilityEvent(event: AccessibilityEvent) {
when (event.eventType) {
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
val className = event.className
if (!TextUtils.isEmpty(className) && className.toString() == "com.tencent.mm.ui.chatting.TextPreviewUI") {
getWechatPreviewTextNode(mService.rootInActiveWindow, object : PreviewTextNodeCallback {
override fun onFound(nodeInfo: AccessibilityNodeInfo?) {
// nodeInfo 即为查找到的文字内容 node
}
})
}
}
}
}

其中 getWechatPreviewTextNode 是查找微信文本详情页 TextView node 的方法,因为该页面仅有一个 TextView 用于展示文字内容,也就是我们要找的那个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface PreviewTextNodeCallback {
fun onFound(nodeInfo: AccessibilityNodeInfo?)
}

fun getWechatPreviewTextNode(node: AccessibilityNodeInfo, callback: PreviewTextNodeCallback) {
for (i in 0 until node.childCount) {
val childNode = node.getChild(i)
if (childNode.className == TextView::class.java.canonicalName) {
callback.onFound(childNode)
return
}
getWechatPreviewTextNode(childNode, callback)
}
}

支持,我们通过模拟双击微信文本 node,成功的获取到了其中的文字内容。但这又引入了一个新的问题,如果我们模拟双击刚好点击在了悬浮球 view 上,点击事件被悬浮球阻挡了怎么办?

问题 2.1: 如何避免模拟双击被悬浮球阻挡

问题描述: 如果模拟点击的位置,刚好是悬浮球所在的位置,则点击事件会被悬浮球消费,导致模拟双击微信文本失败。

解决方案: 只要我们保证模拟点击的位置不与悬浮球重叠即可。在之前的逻辑中,我们点击的位置是悬浮球拖拽的位置,因此我们只需要在悬浮球拖拽时,在悬浮球的左上方显示一个十字锚点,并以该锚点作为拖拽的点进行 node 的匹配,且以该锚点的位置作为模拟点击的位置,这样一来便保证了点击位置不与悬浮球重叠。

这个解决方案也是参考了 fooview 的处理逻辑。在文末提供的 demo 源码中,并没有实现这块逻辑,主要是考虑到 demo 的可读性。感兴趣的小伙伴可以尝试自己实现这块逻辑,并不十分麻烦,但也有更多需要注意的细节,比如锚点并不能永远固定在悬浮球的左上方,否则就永远无法选中屏幕最右边与最底部的内容,具体的处理细节可以参考 fooview 的做法。

问题 2.2: 某些情况下模拟双击失败

问题描述: 模拟点击 api 在某些情况下执行时间接近 1 秒,导致两次点击间隔时间过长,模拟双击失败。

解决方案: 重启手机。除了重启手机外,暂时没有找到该问题的解决方案。模拟点击 api 是通过 AIDL 调用的系统服务,我们并不能通过调试自己的代码发现问题原因。但我们可以做的是,通过记录两次点击的时间间隔,发现这种模拟双击失败的情况,并通知用户,引导用户重启手机。

当上述问题发生时,fooview 也无法识别微信文字内容,因为 fooview 的实现原理也是模拟双击微信文本。因此我们可以认为问题出在 Android 系统本身。

在实际测试中,该问题仅出现在华为手机上,猜测是华为修改了模拟点击 api 的逻辑,可能是为了限制连点器等其他第三方 app 滥用该 api。

问题 3: 辅助功能的不稳定性

问题描述: node 是『实时』状态,这里的『实时』是指你在读取 node 信息时,每一次都是『实时』信息,如果屏幕内容产生变化,两次读取的内容可能不同。在某些极端情况下,上一行代码读取 node 不为 null,下一行再读取就变成 null 了。

解决方案: 添加更多的 null 判断,或用 try/catch 包裹处理逻辑。

参考