问一下,利用在线 DeepSeek 等 API 服务实现一个答题 APP

简介

这是一个利用 Android 无障碍功能 + 悬浮窗 + 大模型的搜题应用

原理就是利用无障碍读取屏幕内容,然后通过悬浮窗来显示答案

众所周知我是一个学渣,所以在搜答案方面颇有成就

大概是在 4 年前,我写了这样一个脚本

GitHub:截图OCR识别后搜索题目获取答案

码云:截图OCR识别后搜索题目获取答案

利用 ADB 对屏幕截图后进行 OCR 识别,然后将识别到的结果用搜索引擎和本地题库进行搜索,然后快速获取答案

前几天,看着我手机里面的李跳跳和 DeepSeek,我突然发现,我可以利用无障碍读取屏幕数据,将读取到的题目发送给 DeepSeek 等大模型进行解答,利用 Android 悬浮窗 来显示答案

说干就干,感觉代码不是特别多,于是就有了这个项目

因为没有做历史记录,提问每次只能问一下,所以这个 APP 就叫问一下了

运行展示

https://www.bilibili.com/video/BV1PrNweHEDX

源码

GitHub:https://github.com/PuZhiweizuishuai/ScanSearch

码云:https://gitee.com/puzhiweizuishuai/ScanSearch

注意:无障碍权限属于敏感权限,请确认软件来源后再安装或者自己编译安装,避免造成损失

技术实现

无障碍

首先到 AndroidManifest.xml 配置无障碍服务

xml 复制代码
        <!-- 注册无障碍服务 -->
        <service android:name=".service.ScreenReaderService"
            android:exported="true"
            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/accessibility_service_config" />
        </service>

创建 app/src/main/res/xml/accessibility_service_config.xml 无障碍配置文件

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeAllMask"
    android:accessibilityFlags="flagDefault"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:canRetrieveWindowContent="true"
    android:notificationTimeout="100" />

编写 无障碍服务代码

kotlin 复制代码
package com.buguagaoshu.scan.search.service
import android.accessibilityservice.AccessibilityService
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import com.buguagaoshu.scan.search.config.StaticVariableConfig
import com.buguagaoshu.scan.search.data.ScanSearchData
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid


class ScreenReaderService : AccessibilityService() {

    // 指定按钮的包名和类名,需要根据实际情况修改
    private val targetButtonPackageName = "com.buguagaoshu.scan.search"
    private val targetButtonClassName = "androidx.compose.material3.Button"
    // 指定按钮的文本内容,需要根据实际情况修改
    private val targetButtonText = "读取屏幕"

    override fun onAccessibilityEvent(event: AccessibilityEvent) {
        if (!StaticVariableConfig.openScan) {
            return
        }

        if (event.packageName == targetButtonPackageName) {
            return
        }

        // 监听滑动事件
        if (event.eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED || event.eventType == AccessibilityEvent.TYPE_VIEW_HOVER_ENTER) {
            // 判断事件类型是否为点击事件
            val source = event.source
            // 检查点击的节点是否符合目标按钮的条件
            if (source != null) {
                // TODO 增加指定包名过滤配置
                println(source.packageName)
            }

            // 获取根节点信息
            val rootNode = rootInActiveWindow
            if (rootNode != null) {
                // 清除之前的数据
                StaticVariableConfig.screenTextList.clear();
                // 遍历节点并读取内容
                traverseNodeLoop(rootNode)
            }
        }

    }

    @OptIn(ExperimentalUuidApi::class)
    private fun traverseNodeLoop(node: AccessibilityNodeInfo) {
        val stack = mutableListOf<AccessibilityNodeInfo>()
        stack.add(node)

        while (stack.isNotEmpty()) {
            val currentNode = stack.removeAt(stack.size - 1)

            // 读取节点的文本内容
            val text = currentNode.text
            if (text != null && text.isNotEmpty()) {
                // 存储数据
                StaticVariableConfig.screenTextList.add(
                    ScanSearchData(Uuid.random().toString(), text.toString())
                )
            }

            // 遍历子节点并将它们添加到栈中
            for (i in currentNode.childCount - 1 downTo 0) {
                val child = currentNode.getChild(i)
                if (child != null) {
                    stack.add(child)
                }
            }
        }
    }

    private fun traverseNode(node: AccessibilityNodeInfo) {
        // 读取节点的文本内容
        val text = node.text
        if (text != null && text.isNotEmpty()) {
            println(text)
        }

        // 遍历子节点
        for (i in 0 until node.childCount) {
            val child = node.getChild(i)
            if (child != null) {
                traverseNode(child)
            }
        }
    }

    override fun onInterrupt() {
        // 服务中断时的处理
    }
}

监控滑动事件,跳过对自身 APP 的监控,将读取到的屏幕数据保存到缓存中,方便后期读取加载

流式响应

因为目前兼容 Open API 的服务都支持流式输出,提升用户体验,避免出现长时间的等待

所以在使用 okhttp 发送请求的时候不能使用 call.execute(),而需要使用 client.newCall(req).enqueue

完整代码实现如下

kotlin 复制代码
    fun sendStream(
        sendData: SendData,
        url: String,
        key: String,
        onChunkReceived: (String) -> Unit,
        onComplete: () -> Unit,
        onError: (String) -> Unit
    ) {
        CoroutineScope(Dispatchers.IO).launch {
            val body = Json.encodeToString(sendData).toRequestBody(contentType)
            // Authorization: Bearer $DASHSCOPE_API_KEY
            val req = Request
                .Builder()
                .url(url)
                .post(body)
                .addHeader("Authorization", "Bearer $key")
                .build()
            client.newCall(req).enqueue(object : Callback {
                override fun onFailure(call: Call, e: IOException) {
                    e.message?.let { onError(it) }
                }

                override fun onResponse(call: Call, response: Response) {
                    response.use {
                        if (!response.isSuccessful) {
                            return
                        }
                        val reader = response.body.charStream()
                        reader.forEachLine { line ->
                            if (line.isNotBlank()) {

                                onChunkReceived(line)
                            }
                        }
                        onComplete()
                    }
                }
            })
        }
    }

其它

UI实现大部分都是通过AI写的,也没有什么要注意的了

使用指南

一、配置无障碍权限与悬浮窗权限

第一次使用会弹出无障碍权限配置菜单

在已下载应用内点击问一下

给予问一下无障碍权限

二、配置 API 服务商

首先你需要配置好你的 AI 服务商,当前你也可以不用配置这个,直接使用悬浮窗内打开网页进行搜索,不过由于 Android Webview 控件我不太会用,显示的效果有问题

由于 DeepSeek 的服务目前用不了,暂时用阿里通义的 API 替代

API申请地址:https://platform.deepseek.com/usage

登陆后点击侧边栏 API keys 生成一个 API_KEY

然后到 APP 内填写你需要调用的大模型名称、 API 地址、和 API-KEY 即可使用

其它可以白嫖的API服务地址

字节火山:https://www.volcengine.com/product/doubao

目前免费送 50 万 TOKE,支持满血 DeepSeek R1 模型

阿里通义:https://www.aliyun.com/minisite/goods?userCode=4i6gwidx

免费送 100 万 TOKEN

三、开始使用

打开悬浮窗后,进入你要搜索的应用

由于只监听了滑动事件,所以进入应用后请先在屏幕上划两下,然后再点击加载数据

这是应该就可以读取到屏幕上显示内容了

将你要搜索的题目进行勾选

点击确定

这样题目就会自动出现再搜索框

⚠️注意:如果需要对题目进行编辑,请先点击打开 ⌨️ 键盘获取焦点,不然无法输入,修改完成后请点击关闭键盘,读取屏幕数据会无法读取到当前屏幕信息

为避免滑动事件冲突,如果需要挪动窗口位置,请先点击右上角的锁,挪动完成后,再点击一下锁就可以再次滑动显示内容

最后点击提问即可获取答案

四、其它功能

点击打开网页可以调用 秘塔AI搜索,不过由于显示界面太小,所以显示会有一些问题

点击最小化按钮可以缩小悬浮窗,这时你可以利用手机系统自带的 AI 功能对屏幕进行识别,获取问题信息

版权

本文首发于:https://www.buguagaoshu.com/archives/wen-yi-xia

转载请注明出处

相关推荐
NPE~3 小时前
[AI]Mac本地部署Deepseek R1模型 — — 保姆级教程
macos·ai·大模型·deepseek·部署教程
蓝创精英团队4 小时前
基于Ubuntu Ollama 部署 DeepSeek-R132B 聊天大模型(附带流式接口调用示例)
linux·运维·ubuntu·deepseek
小华同学ai6 小时前
ChatGPT-on-WeChat:Star32.4k, DeepSeek加持!这款开源神器秒变AI助手,聊天体验直接起飞!
github·deepseek
岁月如歌,青春不败6 小时前
DeepSeek与GPT大语言模型教程
人工智能·python·gpt·深度学习·机器学习·语言模型·deepseek
架构技术专栏6 小时前
Dify 如何连接 AWS Bedrock 知识库?
人工智能·deepseek
BugNest6 小时前
图像处理技术和应用
图像处理·人工智能·深度学习·机器学习·计算机视觉·ai
大龄码农有梦想7 小时前
SpringBoot集成Milvus,实现数据增删改查
人工智能·spring boot·ai·springboot·milvus·向量数据库
程序设计实验室7 小时前
AI 如何重塑劳动力市场:基于 Claude 数据的深度分析
ai·llm·翻译
一梦南柯7 小时前
开发新体验:基于Ollama+deepseek打造私有化代码助手
人工智能·visual studio code·deepseek
致Great7 小时前
DeepSeek 背后的数学原理:深入探究群体相对策略优化 (GRPO)
llm·deepseek