简介:这份资源是面向Android开发者与FGO玩家的自动战斗应用FGA(Fate/Grand Order Auto Battle)配套资料,聚焦手游日常任务与重复战斗的自动化实现,适合想研究无障碍服务、屏幕捕获与图像识别落地的中高级Android学习者。压缩包为zip格式,整体约2.62MB,上游未提供文件明细,故不展开具体文件构成。目前已有2613人学习下载,具备一定参考热度。内容围绕Kotlin语言构建应用架构,结合OpenCV对游戏画面中的敌人位置、血条与技能图标进行识别,并借助Accessibility无障碍服务完成自动点击与滑动,通过MediaProjection获取屏幕画面以支撑识别流程,形成从画面采集到策略执行的完整链路。读者可从中理解多技术框架协同的工程组织方式,掌握图像识别与自动化操作的排错思路,并了解此类工具在游戏条款与账号风险方面的边界。
1. 拿到 FGA 源码包先别急着编译:它到底替你干了哪几件事
FGA 是一个面向 Android 端的自动战斗应用,技术栈是 Kotlin + OpenCV + AccessibilityService + MediaProjection。说白了,它把「看屏幕」和「点屏幕」这两件事拆开做:MediaProjection 负责抓实时画面,OpenCV 做模板匹配和颜色判定,AccessibilityService 负责把识别结果翻译成点击、滑动这类无障碍动作。你手上这份资源就是这套链路的完整工程,适合两类人:一是想研究 Android 端图像识别 + 自动化落地的开发者,二是想把这套骨架迁移到自己项目里的从业者。它解决的核心痛点是——不用 root、不用改游戏包,纯靠系统级权限把「截图→识别→操作」闭环跑通。但先说清楚,这不是装完就能无脑挂机的成品,参数、分辨率、模板图都得自己调,玄学的地方不少。
2. 工程结构与三条核心链路:先看懂再动手
拿到源码包,第一件事不是点 Run,而是把目录和依赖关系捋清楚。FGA 这类项目的工程结构通常围绕「采集层 / 识别层 / 执行层」三段式组织,Kotlin 负责调度和 UI,OpenCV 的 native so 负责图像运算,AccessibilityService 和 MediaProjection 是两个系统级入口。你要是上来就改业务逻辑,很容易在权限和生命周期上翻车。
2.1 模块划分与依赖关系
一个合格的 FGA 工程,模块大致是这样切的:
| 模块 | 职责 | 关键类/文件 | 依赖 |
|---|---|---|---|
| app | 主界面、配置、战斗流程调度 | MainActivity、BattleTask | 全部 |
| capture | 屏幕采集 | ScreenCaptureService | MediaProjection |
| vision | 图像识别 | Matcher、ColorDetector | OpenCV |
| action | 无障碍操作 | AutoAccessibilityService | AccessibilityService |
| data | 模板图、配置持久化 | TemplateRepository | 本地存储 |
依赖方向是 app → capture/vision/action,vision 不直接依赖 action,中间靠 BattleTask 做编排。这个分层很重要:识别层只输出「坐标 + 置信度」,执行层只认坐标,两边解耦之后你换识别算法不用动点击逻辑。常见做法是把 OpenCV 的初始化放在 Application 里做一次OpenCVLoader.initLocal(),别在每个 Service 里重复 init,否则 so 加载会出问题。
// Application 中统一初始化 OpenCV,避免多 Service 重复加载 native 库 class FgaApp : Application() { override fun onCreate() { super.onCreate() val ok = OpenCVLoader.initLocal() // 4.x 用 initLocal,老版本是 initDebug if (!ok) { Log.e("FGA", "OpenCV 初始化失败,检查 abiFilters 是否包含当前设备架构") } } }这段代码的逻辑是:OpenCV 的 native 库按 ABI 分包,initLocal()会去加载libopencv_java4.so。参数上要注意abiFilters,如果你只打了arm64-v8a,在 32 位模拟器上就会加载失败,日志里表现为UnsatisfiedLinkError。我一般会在build.gradle里显式写ndk { abiFilters 'arm64-v8a', 'armeabi-v7a' },多打一个包换兼容性,值。
2.2 MediaProjection 采集链路
采集这块是整个项目最容易出问题的地方。MediaProjection 从 Android 10 开始要求前台服务配合foregroundServiceType="mediaProjection",Android 14 之后还要求每次投屏授权都重新申请,不能复用 token。FGA 的采集服务典型写法是:
// 启动前台服务并申请投屏,注意 Android 14 必须每次重新请求 private fun startCapture(resultCode: Int, data: Intent) { val manager = getSystemService(MediaProjectionManager::class.java) val projection = manager.getMediaProjection(resultCode, data) // 注册回调,否则部分机型拿到 projection 后立即失效 projection.registerCallback(object : MediaProjection.Callback() { override fun onStop() { Log.w("FGA", "投屏被系统回收,需要重新申请") } }, Handler(Looper.getMainLooper())) val metrics = resources.displayMetrics val density = metrics.densityDpi val width = metrics.widthPixels val height = metrics.heightPixels // 用 ImageReader 拿 RGBA_8888,后续转 Mat 做识别 val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2) projection.createVirtualDisplay( "fga-capture", width, height, density, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, reader.surface, null, null ) }逻辑说明:getMediaProjection拿到的对象必须注册Callback,否则某些 ROM 会在几秒后静默回收,表现为「截图全是黑屏」。ImageReader的maxImages设 2 是为了双缓冲,设 1 容易在识别耗时的时候丢帧。参数上PixelFormat.RGBA_8888是必须的,OpenCV 转 Mat 时按 4 通道处理,用 RGB_565 会颜色失真导致模板匹配失败。这里有个血泪经验:createVirtualDisplay的宽高一定要用真实屏幕分辨率,缩放会导致模板图对不上,后面识别全废。
2.3 AccessibilityService 执行链路
识别出坐标之后,点击动作走 AccessibilityService。FGA 里通常封装一个click(x, y)方法,底层是dispatchGesture:
// 通过无障碍手势执行点击,注意 API 24+ 才支持 dispatchGesture fun click(x: Float, y: Float) { val path = Path().apply { moveTo(x, y) } val gesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(path, 0, 50)) // 50ms 按压 .build() dispatchGesture(gesture, object : GestureResultCallback() { override fun onCompleted(desc: GestureDescription?) { /* 点击完成 */ } override fun onCancelled(desc: GestureDescription?) { /* 被系统打断,常见于弹窗 */ } }, null) }参数上StrokeDescription的 duration 设 50ms 是经验值,太短(<20ms)部分游戏识别不到,太长(>200ms)会被判定成长按。onCancelled一定要处理,游戏里弹个公告、来个系统弹窗,手势就被打断,如果你不重试,战斗流程就卡死在那一步。常见做法是在onCancelled里做一次延迟重试,重试两次还失败就上报异常、暂停任务。
3. 从零跑通一次自动战斗:配置、模板与识别参数
工程看懂了,接下来是让它真正跑起来。这一章按「装 → 配 → 识别 → 执行」的顺序走,每一步都给出可抄的配置和参数。新手照着做能出结果,熟手重点看参数边界。
3.1 权限清单与前台服务声明
FGA 需要的权限不多但都敏感,AndroidManifest.xml里至少要声明这些:
<!-- 无障碍服务,需要在系统设置里手动开启 --> <service android:name=".action.AutoAccessibilityService" android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" android:exported="true"> <intent-filter> <action android:name="android.accessibilityservice.AccessibilityService" /> </intent-filter> <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessibility_config" /> </service> <!-- 投屏前台服务,Android 10+ 必须声明类型 --> <service android:name=".capture.ScreenCaptureService" android:foregroundServiceType="mediaProjection" android:exported="false" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />accessibility_config.xml里要开canPerformGestures="true",否则dispatchGesture直接无效。foregroundServiceType漏写是新手最常见的翻车点,Android 14 上会直接抛SecurityException。另外FOREGROUND_SERVICE_MEDIA_PROJECTION这个权限是 Android 14 新增的,老工程迁移过来必须补。
3.2 模板图制作与匹配参数
识别层的核心是模板匹配。FGA 里通常用Imgproc.matchTemplate配TM_CCOEFF_NORMED,阈值一般卡在 0.8 左右。模板图怎么截有讲究:必须用和目标设备同分辨率的截图裁,缩放过的模板匹配分数会掉到 0.6 以下,直接失效。
// 模板匹配:返回最佳匹配位置和置信度 fun match(screen: Mat, template: Mat, threshold: Double = 0.8): MatchResult? { val result = Mat() // TM_CCOEFF_NORMED 对亮度变化不敏感,适合游戏 UI Imgproc.matchTemplate(screen, template, result, Imgproc.TM_CCOEFF_NORMED) val mmr = Core.minMaxLoc(result) return if (mmr.maxVal >= threshold) { // maxLoc 是模板左上角,加半宽高得到中心点 MatchResult( x = mmr.maxLoc.x + template.cols() / 2.0, y = mmr.maxLoc.y + template.rows() / 2.0, score = mmr.maxVal ) } else null }逻辑说明:TM_CCOEFF_NORMED输出的是归一化相关系数,范围 -1 到 1,越接近 1 越像。阈值 0.8 是通用起点,但游戏里半透明按钮、动态特效会让分数波动,我一般会把关键按钮(比如「开始战斗」)阈值降到 0.75 并加二次校验,非关键按钮保持 0.85 减少误点。maxLoc拿到的是左上角坐标,点击要的是中心点,所以加cols/2和rows/2,这个偏移忘了加,点击就会偏到按钮左上角,表现为「点了没反应」。
颜色判定是另一条路,适合血条、技能 CD 这种纯色区域:
// 判断指定区域是否以某颜色为主,用于血条/技能可用性检测 fun isColorDominant(screen: Mat, roi: Rect, target: Scalar, tolerance: Double = 30.0): Boolean { val region = screen.submat(roi) val hsv = Mat() Imgproc.cvtColor(region, hsv, Imgproc.COLOR_RGBA2HSV) // 在 HSV 空间比较,比 RGB 抗光照 val mean = Core.mean(hsv) return Math.abs(mean.`val`[0] - target.`val`[0]) < tolerance }HSV 空间做颜色判定比 RGB 稳,因为游戏里技能高亮、血条渐变主要影响明度(V),色相(H)相对稳定。tolerance设 30 是经验值,设太小(<10)稍微有点特效就误判,设太大(>50)会把相邻颜色也算进去。
3.3 战斗循环的调度与节流
识别 + 点击串起来就是战斗循环。这里最容易犯的错是不节流,每帧都截图识别,CPU 直接拉满、手机发烫、游戏掉帧。FGA 里一般用固定间隔轮询:
// 战斗主循环:固定间隔采样,避免高频截图拖垮设备 private val loopInterval = 500L // 毫秒,按设备性能调 fun battleLoop() { while (isRunning) { val start = System.currentTimeMillis() val screen = captureOnce() // 从 ImageReader 取最新帧转 Mat if (screen != null) { // 优先级:先判技能可用,再判攻击按钮 val skillReady = isColorDominant(screen, skillRoi, skillColor) if (skillReady) { click(skillX, skillY) } else { match(screen, attackTemplate)?.let { click(it.x.toFloat(), it.y.toFloat()) } } screen.release() // Mat 必须释放,否则 native 内存泄漏 } val cost = System.currentTimeMillis() - start val sleep = (loopInterval - cost).coerceAtLeast(50L) Thread.sleep(sleep) // 保证单轮不低于 50ms,给系统喘息 } }参数说明:loopInterval500ms 是保守值,低端机可以放到 800ms,旗舰机 300ms 也扛得住,但别低于 200ms,否则截图和识别抢 CPU。screen.release()这行千万别漏,OpenCV 的 Mat 是 native 内存,不释放跑几分钟就 OOM,日志里是OutOfMemoryError但堆内存看着正常,这就是黑匣子所在。coerceAtLeast(50L)是兜底,防止单轮耗时超过间隔导致死循环空转。
4. 避坑与排查:那些跑不通的真实原因
这套东西跑不起来,九成不是代码逻辑错,而是环境、权限、分辨率这些外围问题。下面几条是我踩过的,按「现象 → 原因 → 解决」记下来。
4.1 截图全黑或全透明
现象:投屏授权成功,但ImageReader取出来的帧全是黑色或透明。原因通常是createVirtualDisplay的宽高和ImageReader不一致,或者maxImages设太小导致 buffer 被复用。解决:确保ImageReader.newInstance和createVirtualDisplay用同一组宽高,maxImages至少 2,取帧时用acquireLatestImage()而不是acquireNextImage(),后者在慢消费时会阻塞。
4.2 模板匹配分数永远偏低
现象:明明截图和模板肉眼一样,maxVal只有 0.5 左右。原因多半是模板图被缩放或压缩过,或者屏幕截图带了状态栏/导航栏导致整体偏移。解决:模板图必须从同分辨率原始截图裁,别用微信传过的图(会被压缩);识别前把状态栏区域裁掉,或者用Rect限定搜索范围,减少误匹配。
4.3 点击位置偏移
现象:识别坐标对了,但点下去偏了几十像素。原因是dispatchGesture用的是屏幕绝对坐标,而你的 Mat 可能被缩放处理过。解决:如果识别前对 Mat 做了resize,点击坐标要按比例还原回原始分辨率;另外检查是否漏加了模板中心偏移(见 3.2 的cols/2)。
4.4 无障碍服务被系统杀掉
现象:跑一段时间后点击失效,日志里没有异常。原因是部分 ROM 对无障碍服务有省电限制,后台一段时间就回收。解决:在系统设置里给应用加白名单、关掉电池优化;代码里在onServiceConnected和onUnbind打日志,配合前台服务保活,别指望纯后台常驻。
4.5 Android 14 上投屏授权复用失败
现象:第一次授权成功,第二次启动直接抛SecurityException。原因是 Android 14 收紧了 MediaProjection token 的复用,每次都要重新走createScreenCaptureIntent。解决:把授权流程做成每次启动都请求,别缓存resultCode和data;registerCallback里onStop触发后主动清理并提示重新授权。
5. 进阶:把识别准确率从「能用」拉到「稳」
跑通只是及格线,真正难的是稳定性。我一般会做两件事:一是给识别加多尺度兜底,二是把关键判定做成可回放的日志。
多尺度兜底的做法是,模板匹配失败时,把屏幕按 0.9、1.0、1.1 三个尺度各试一次,取最高分。代价是耗时翻三倍,所以只对关键按钮用,普通按钮单尺度就够。代码上就是在match外面包一层循环,Imgproc.resize改 screen 的尺寸,坐标再按比例还原。
// 多尺度匹配:关键按钮专用,容忍轻微分辨率差异 fun matchMultiScale(screen: Mat, template: Mat, scales: List<Double> = listOf(0.9, 1.0, 1.1)): MatchResult? { var best: MatchResult? = null for (s in scales) { val resized = Mat() Imgproc.resize(screen, resized, Size(screen.cols() * s, screen.rows() * s)) val r = match(resized, template) if (r != null && (best == null || r.score > best!!.score)) { // 坐标要除以缩放系数还原 best = r.copy(x = r.x / s, y = r.y / s) } resized.release() } return best }日志回放这块,我的习惯是每次识别失败时把当前帧存成 PNG,文件名带上时间戳和判定类型。跑一晚上攒几十张失败样本,第二天一看就知道是模板过期了还是阈值太严。这个习惯救过我很多次——有次某个按钮改版,模板匹配分数从 0.9 掉到 0.7,就是靠失败样本一眼看出来的,不然还在那调阈值调到怀疑人生。
参数上再补一个经验值:TM_CCOEFF_NORMED在纯色背景上容易给出虚高分,所以匹配前最好先做一次边缘检测或者限定 ROI,别全屏搜。全屏搜还有个问题是耗时,1080p 全屏匹配一张 100x100 的模板大概 30-50ms,限定到按钮区域能降到 5ms 以内,对循环频率影响很大。
从那以后我每次改识别逻辑,都强制先跑一遍失败样本回放,确认新参数在历史失败帧上也能过,再上真机。这套流程看着笨,但比在真机上盲调省太多时间。希望帮到你。
本文还有配套的精品资源,点击获取