效果展示
方案概述 方案实现的大致步骤如下:
注册辅助功能服务,通过辅助功能获取屏幕内容
筛选屏幕中的文字内容,并记录每一块文字在屏幕中的位置
显示悬浮球,实现其拖拽逻辑
根据悬浮球拖拽的坐标,匹配第 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 <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
。
通过辅助功能获取屏幕上的文字内容 通过 AssistService
的 getRootInActiveWindow()
方法,可以获取屏幕内容的根节点,从而遍历获取包含文字内容的节点。
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 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) if (itemRect.contains(point.x, point.y)) { 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 步:
识别微信文本节点,并将该类型的节点也从屏幕所有节点中筛选出来
当拖拽到微信文本节点时,模拟双击微信文本
监听屏幕内容变化的 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) 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 ?) { } }) } } } }
其中 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 包裹处理逻辑。
参考