前言：一个咸鱼玩家的觉醒事情是这样的。某天我下载了一款叫《咸鱼之王》的游戏。作为一个放置类游戏，它的核心玩法是——点点点。每日任务要点领奖励要点打副本要点抽卡要点合成要点……玩了一周后，我的手指已经开始抗议了。作为一个程序员，我陷入了深深的思考：我玩游戏，还是游戏玩我？于是，"猫吃鱼"项目诞生了——让脚本替我吃掉这条咸鱼。第一章：技术选型——为什么是 Auto.js1.1 安卓自动化方案对比方案优点缺点Auto.js无需 root，基于无障碍服务，JavaScript 语法官方版已下架，需要用开源版Tasker功能强大，生态丰富学习曲线陡峭，自动化能力有限ADB + Python灵活，可电脑控制需要连接电脑，不够便携按键精灵简单易用功能有限，很多要付费Root + 触摸模拟权限最高，啥都能干需要 root，有变砖风险最终选择 Auto.js（准确说是开源版 AutoX.js），原因：无需 root：基于安卓无障碍服务（Accessibility Service）JavaScript 语法：前端出身的我表示很亲切开源免费：AutoX.js 是社区维护的开源版本功能全面：控件操作、图色识别、悬浮窗、定时任务一应俱全1.2 环境搭建# 1. 下载 AutoX.js # GitHub: https://github.com/autox-community/AutoX # 下载 APK 安装到手机 # 2. 开启无障碍服务 # 设置 -> 无障碍 -> AutoX.js -> 开启 # 3. 开启悬浮窗权限 # 设置 -> 应用管理 -> AutoX.js -> 悬浮窗权限 # 4. 关闭电池优化（重要！） # 设置 -> 电池 -> AutoX.js -> 不优化 # 否则后台运行会被杀掉踩坑提醒：不同手机厂商的设置路径不一样，MIUI、ColorOS、EMUI 各有各的"特色"。有时候需要在"自启动管理"、"后台运行"等多个地方设置。第二章：无障碍服务——脚本的灵魂2.1 什么是无障碍服务无障碍服务（Accessibility Service）本来是安卓为视障人士设计的功能，可以：读取屏幕内容：获取当前界面所有控件的信息模拟用户操作：点击、滑动、输入文字监听系统事件：界面变化、通知到达等简单说，开启无障碍服务后，你的脚本就像一个"看得见屏幕的机器人"，可以知道屏幕上有什么，然后去操作它。2.2 控件 vs 坐标Auto.js 有两种主要的操作方式：方式一：基于控件（推荐） // 通过文字找到按钮并点击 click("领取奖励"); // 通过 id 找到控件 id("btn_claim").findOne().click(); // 通过描述找到控件 desc("关闭").findOne().click();方式二：基于坐标 // 直接点击屏幕坐标 click(540, 1200); // 滑动 swipe(500, 1500, 500, 500, 500);对比：特性控件操作坐标操作适配性好，不同分辨率通用差，换手机就废稳定性高，控件存在才操作低，可能点到别的地方速度稍慢，需要查找控件快，直接点适用场景原生应用、有控件信息的界面游戏画面、Canvas 渲染的界面《咸鱼之王》的尴尬在于——它是个混合应用，部分界面有控件信息，部分界面是纯游戏渲染，需要两种方式结合使用。2.3 布局分析——找到控件的"身份证"Auto.js 自带布局分析功能，可以查看当前界面的控件树： // 方法一：悬浮窗里点"布局分析" // 方法二：代码打印 auto.waitFor(); // 等待无障碍服务启动 let root = className("android.widget.FrameLayout").findOne(); log(root); // 打印控件树布局分析会显示每个控件的：text：显示的文字desc：描述（contentDescription）id：资源 IDclassName：控件类型bounds：位置和大小技巧：优先用 text 和 id，它们最稳定。第三章：项目架构——"猫吃鱼"的设计3.1 功能规划作为一个有追求的脚本，"猫吃鱼"要实现：猫吃鱼 ├── 日常任务 │ ├── 一键领取所有奖励 │ ├── 自动完成日常任务 │ └── 自动观看广告 ├── 战斗系统 │ ├── 自动挂机刷副本 │ ├── 自动打竞技场 │ └── 自动参与活动 ├── 资源管理 │ ├── 自动合成装备 │ ├── 自动强化英雄 │ └── 自动使用体力 └── 辅助功能 ├── 防检测（随机延迟） ├── 异常处理（弹窗、卡顿） └── 运行日志3.2 代码结构cat_eat_fish/ ├── main.js # 主入口 ├── config.js # 配置文件 ├── modules/ │ ├── daily.js # 日常任务 │ ├── battle.js # 战斗相关 │ ├── resource.js # 资源管理 │ └── utils.js # 工具函数 ├── lib/ │ ├── ui.js # 悬浮窗 UI │ ├── logger.js # 日志模块 │ └── ocr.js # 文字识别（可选） └── images/ # 图片资源（用于找图） ├── btn_claim.png ├── icon_close.png └── ...3.3 配置文件设计// config.js module.exports = { // 游戏包名 packageName: "com.xianyuwang.game", // 功能开关 features: { autoDailyTask: true, // 自动日常 autoWatchAd: false, // 自动看广告（费流量） autoBattle: true, // 自动战斗 autoMerge: true, // 自动合成 }, // 时间配置（毫秒） timing: { shortDelay: [300, 600], // 短延迟范围 mediumDelay: [800, 1500], // 中延迟范围 longDelay: [2000, 4000], // 长延迟范围 pageLoadWait: 2000, // 页面加载等待 }, // 战斗配置 battle: { maxRounds: 100, // 最大战斗轮数 autoRetry: true, // 失败自动重试 }, // 安全配置 safety: { randomDelay: true, // 随机延迟（防检测） maxRunTime: 3600000, // 最大运行时间（1小时） } };第四章：核心代码实现4.1 工具函数——脚本的基础设施 // modules/utils.js /** * 随机延迟，模拟人类操作 */ function randomSleep(min, max) { let delay = Math.floor(Math.random() * (max - min + 1)) + min; sleep(delay); return delay; } /** * 安全点击 - 基于文字 * @param {string} text - 要点击的文字 * @param {number} timeout - 超时时间（毫秒） * @returns {boolean} - 是否点击成功 */ function safeClickText(text, timeout = 5000) { let target = textContains(text).findOne(timeout); if (target) { let bounds = target.bounds(); // 点击控件中心，加一点随机偏移 let x = bounds.centerX() + random(-5, 5); let y = bounds.centerY() + random(-5, 5); click(x, y); log(`点击成功: ${text}`); randomSleep(300, 600); return true; } log(`未找到: ${text}`); return false; } /** * 安全点击 - 基于 ID */ function safeClickId(targetId, timeout = 5000) { let target = id(targetId).findOne(timeout); if (target) { target.click(); log(`点击成功: ${targetId}`); randomSleep(300, 600); return true; } log(`未找到: ${targetId}`); return false; } /** * 等待页面加载 */ function waitForPage(identifier, timeout = 10000) { let startTime = Date.now(); while (Date.now() - startTime < timeout) { if (text(identifier).exists() || id(identifier).exists()) { log(`页面已加载: ${identifier}`); randomSleep(500, 1000); return true; } sleep(500); } log(`页面加载超时: ${identifier}`); return false; } /** * 处理常见弹窗 */ function handlePopups() { // 关闭按钮 if (desc("关闭").exists()) { desc("关闭").findOne().click(); log("关闭弹窗"); return true; } // 确定按钮 if (text("确定").exists()) { click("确定"); log("点击确定"); return true; } // 广告关闭 if (id("ad_close").exists()) { id("ad_close").findOne().click(); log("关闭广告"); return true; } return false; } /** * 返回主界面 */ function goHome() { let maxTries = 5; while (maxTries > 0) { if (text("主城").exists() || id("main_page").exists()) { log("已在主界面"); return true; } back(); randomSleep(800, 1200); handlePopups(); maxTries--; } log("返回主界面失败"); return false; } module.exports = { randomSleep, safeClickText, safeClickId, waitForPage, handlePopups, goHome };4.2 日常任务模块 // modules/daily.js const utils = require('./utils.js'); const config = require('../config.js'); /** * 领取所有可领取的奖励 */ function claimAllRewards() { log("===== 开始领取奖励 ====="); // 领取邮件奖励 claimMailRewards(); // 领取任务奖励 claimTaskRewards(); // 领取活动奖励 claimEventRewards(); log("===== 奖励领取完成 ====="); } /** * 领取邮件奖励 */ function claimMailRewards() { log("检查邮件..."); // 点击邮件图标 if (!utils.safeClickText("邮件")) { log("未找到邮件入口"); return; } utils.waitForPage("邮件列表"); // 一键领取 if (text("一键领取").exists()) { utils.safeClickText("一键领取"); utils.randomSleep(1000, 1500); utils.handlePopups(); // 处理领取成功弹窗 } else { log("没有可领取的邮件"); } utils.goHome(); } /** * 领取任务奖励 */ function claimTaskRewards() { log("检查任务奖励..."); // 打开任务界面 if (!utils.safeClickText("任务")) { log("未找到任务入口"); return; } utils.waitForPage("每日任务"); // 循环领取所有可领取的奖励 let claimed = 0; while (true) { // 查找"领取"按钮 let claimBtn = text("领取").findOne(2000); if (claimBtn) { claimBtn.click(); claimed++; utils.randomSleep(500, 800); utils.handlePopups(); } else { break; } // 防止死循环 if (claimed > 20) break; } log(`共领取 ${claimed} 个任务奖励`); utils.goHome(); } /** * 自动完成简单任务 */ function autoCompleteTasks() { log("===== 开始自动任务 ====="); // 每日签到 dailyCheckIn(); // 免费抽卡 freeGacha(); // 领取体力 claimStamina(); // 快速战斗 quickBattle(); log("===== 自动任务完成 ====="); } /** * 每日签到 */ function dailyCheckIn() { log("执行每日签到..."); if (utils.safeClickText("签到")) { utils.waitForPage("签到"); // 点击签到按钮 if (text("签到").exists()) { utils.safeClickText("签到"); utils.randomSleep(1000, 1500); } utils.handlePopups(); utils.goHome(); } } /** * 免费抽卡 */ function freeGacha() { log("检查免费抽卡..."); if (utils.safeClickText("召唤")) { utils.waitForPage("召唤"); // 查找免费按钮 if (textContains("免费").exists()) { utils.safeClickText("免费"); utils.randomSleep(3000, 4000); // 等待抽卡动画 // 跳过动画或关闭结果 click(device.width / 2, device.height / 2); utils.randomSleep(500, 800); utils.handlePopups(); } else { log("今日免费次数已用完"); } utils.goHome(); } } module.exports = { claimAllRewards, autoCompleteTasks };4.3 战斗模块 // modules/battle.js const utils = require('./utils.js'); const config = require('../config.js'); /** * 自动刷副本 * @param {string} dungeonName - 副本名称 * @param {number} times - 刷取次数 */ function autoDungeon(dungeonName, times = 10) { log(`===== 开始刷副本: ${dungeonName} =====`); // 进入副本界面 if (!enterDungeon(dungeonName)) { log("进入副本失败"); return; } let successCount = 0; let failCount = 0; for (let i = 0; i < times; i++) { log(`第 ${i + 1}/${times} 次战斗`); // 检查体力 if (!checkStamina()) { log("体力不足，停止刷副本"); break; } // 开始战斗 if (startBattle()) { // 等待战斗结束 let result = waitBattleEnd(); if (result === "win") { successCount++; log(`战斗胜利！当前 ${successCount} 胜 ${failCount} 负`); } else if (result === "lose") { failCount++; log(`战斗失败... 当前 ${successCount} 胜 ${failCount} 负`); if (!config.battle.autoRetry) { log("配置为失败不重试，停止"); break; } } else { log("战斗状态异常"); utils.handlePopups(); } } else { log("开始战斗失败"); break; } // 战斗间隔 utils.randomSleep(1500, 2500); } log(`副本完成: ${successCount} 胜 ${failCount} 负`); utils.goHome(); } /** * 进入副本 */ function enterDungeon(dungeonName) { // 打开副本界面 if (!utils.safeClickText("副本") && !utils.safeClickText("冒险")) { return false; } utils.randomSleep(1000, 1500); // 选择具体副本 if (utils.safeClickText(dungeonName)) { utils.randomSleep(800, 1200); return true; } // 如果没找到，可能需要滑动查找 for (let i = 0; i < 3; i++) { swipe(540, 1500, 540, 800, 500); utils.randomSleep(500, 800); if (utils.safeClickText(dungeonName)) { utils.randomSleep(800, 1200); return true; } } return false; } /** * 开始战斗 */ function startBattle() { // 点击挑战按钮 if (utils.safeClickText("挑战") || utils.safeClickText("战斗") || utils.safeClickText("开始")) { utils.randomSleep(1500, 2000); return true; } return false; } /** * 等待战斗结束 */ function waitBattleEnd(timeout = 120000) { let startTime = Date.now(); while (Date.now() - startTime < timeout) { // 检查胜利 if (textContains("胜利").exists() || textContains("Victory").exists()) { utils.randomSleep(1000, 1500); // 点击继续或关闭 utils.safeClickText("继续") || utils.safeClickText("确定") || click(540, 1600); return "win"; } // 检查失败 if (textContains("失败").exists() || textContains("Defeat").exists()) { utils.randomSleep(1000, 1500); utils.safeClickText("确定") || click(540, 1600); return "lose"; } // 处理可能的弹窗 utils.handlePopups(); sleep(1000); } log("战斗超时"); return "timeout"; } /** * 检查体力是否充足 */ function checkStamina(minStamina = 10) { // 这里需要根据游戏实际 UI 来获取体力值 // 方法1：OCR 识别 // 方法2：找特定控件读取 // 方法3：检查"体力不足"提示 // 简化处理：如果没有"体力不足"提示就认为够用 return !textContains("体力不足").exists(); } /** * 自动竞技场 */ function autoArena(times = 5) { log(`===== 开始竞技场 ${times} 次 =====`); // 进入竞技场 if (!utils.safeClickText("竞技场") && !utils.safeClickText("PVP")) { log("未找到竞技场入口"); return; } utils.waitForPage("竞技场"); for (let i = 0; i < times; i++) { log(`竞技场第 ${i + 1}/${times} 场`); // 选择对手（选第一个，一般最弱） let opponent = className("android.widget.LinearLayout") .findOne(3000); if (opponent) { opponent.click(); utils.randomSleep(500, 800); // 点击挑战 if (startBattle()) { waitBattleEnd(60000); // 竞技场战斗一般较快 } } else { log("未找到对手"); break; } utils.randomSleep(2000, 3000); } utils.goHome(); } module.exports = { autoDungeon, autoArena };4.4 主程序入口 // main.js "ui"; // 启用 UI 模式 const config = require('./config.js'); const daily = require('./modules/daily.js'); const battle = require('./modules/battle.js'); const utils = require('./modules/utils.js'); // ==================== UI 界面 ==================== ui.layout( <text text="🐱 猫吃鱼 - 咸鱼之王助手" textSize="22sp" textColor="#333333" gravity="center" marginBottom="16"/> <checkbox id="cbDaily" text="日常任务" checked="true"/> <checkbox id="cbBattle" text="自动副本" checked="true" marginLeft="16"/> <checkbox id="cbArena" text="竞技场" checked="false"/> <checkbox id="cbAd" text="看广告" checked="false" marginLeft="16"/> <text text="副本次数: "/> <text text="运行日志:" marginTop="16"/> <text id="txtLog" textSize="12sp" textColor="#666666"/> <text text="提示: 请先打开游戏，再点击开始运行" textSize="12sp" textColor="#999999" marginTop="8"/> ); // ==================== 全局变量 ==================== let isRunning = false; let mainThread = null; // ==================== 日志函数 ==================== function appendLog(msg) { let time = new Date().toLocaleTimeString(); let logText = `[${time}] ${msg}\n`; ui.run(() => { ui.txtLog.setText(ui.txtLog.text() + logText); }); console.log(msg); } // 重写 log 函数 log = appendLog; // ==================== 事件处理 ==================== ui.btnStart.on("click", () => { if (isRunning) { toast("脚本正在运行中"); return; } // 检查无障碍服务 if (!auto.service) { toast("请先开启无障碍服务"); app.startActivity({ action: "android.settings.ACCESSIBILITY_SETTINGS" }); return; } isRunning = true; ui.btnStart.setText("运行中..."); mainThread = threads.start(function() { try { runScript(); } catch (e) { log("发生错误: " + e); } finally { isRunning = false; ui.run(() => { ui.btnStart.setText("开始运行"); }); } }); }); ui.btnStop.on("click", () => { if (mainThread) { mainThread.interrupt(); mainThread = null; } isRunning = false; ui.btnStart.setText("开始运行"); log("脚本已停止"); }); // ==================== 主逻辑 ==================== function runScript() { log("🐱 猫吃鱼启动！"); // 确保在游戏中 let packageName = config.packageName; if (currentPackage() !== packageName) { log("正在启动游戏..."); app.launchPackage(packageName); sleep(8000); // 等待游戏加载 } // 处理可能的开屏弹窗 utils.randomSleep(2000, 3000); utils.handlePopups(); // 执行日常任务 if (ui.cbDaily.checked) { daily.claimAllRewards(); daily.autoCompleteTasks(); } // 执行自动副本 if (ui.cbBattle.checked) { let times = parseInt(ui.inputTimes.text()) || 10; battle.autoDungeon("普通副本", times); } // 执行竞技场 if (ui.cbArena.checked) { battle.autoArena(5); } log("🎉 所有任务完成！"); } // ==================== 脚本退出处理 ==================== events.on("exit", function() { if (mainThread) { mainThread.interrupt(); } log("脚本退出"); });第五章：进阶技巧——让脚本更稳定5.1 图色识别——当控件失效时有些游戏界面是纯 Canvas 绘制的，没有控件信息。这时需要用图色识别： // 找图 let img = images.read("/sdcard/cat_eat_fish/images/btn_claim.png"); let point = findImage(captureScreen(), img, { threshold: 0.8, // 相似度阈值 region: [0, 0, device.width, device.height] // 搜索区域 }); if (point) { log(`找到图片: (${point.x}, ${point.y})`); click(point.x + 10, point.y + 10); // 点击图片中心偏移 } img.recycle(); // 释放图片资源，很重要！ // 找色 let color = "#FF5722"; // 目标颜色 let p = findColor(captureScreen(), color, { region: [100, 200, 300, 400], threshold: 10 }); if (p) { log(`找到颜色: (${p.x}, ${p.y})`); }注意：找图前需要获取截图权限：if (!requestScreenCapture()) { toast("请授予截图权限"); exit(); }5.2 多分辨率适配// 获取设备信息 let screenWidth = device.width; let screenHeight = device.height; // 设计基准（以 1080x1920 为基准） const BASE_WIDTH = 1080; const BASE_HEIGHT = 1920; // 坐标转换函数 function scaleX(x) { return Math.floor(x * screenWidth / BASE_WIDTH); } function scaleY(y) { return Math.floor(y * screenHeight / BASE_HEIGHT); } // 使用 click(scaleX(540), scaleY(1200));5.3 异常恢复机制 /** * 带重试的操作包装器 */ function withRetry(action, maxRetries = 3, delay = 1000) { for (let i = 0; i < maxRetries; i++) { try { if (action()) { return true; } } catch (e) { log(`操作失败 (${i + 1}/${maxRetries}): ${e}`); } sleep(delay); } return false; } /** * 全局异常处理 */ function globalErrorHandler() { // 检测是否还在游戏中 if (currentPackage() !== config.packageName) { log("检测到离开游戏，尝试返回"); app.launchPackage(config.packageName); sleep(5000); return; } // 处理常见弹窗 if (utils.handlePopups()) { return; } // 尝试返回主界面 utils.goHome(); } // 定时执行异常检测 setInterval(globalErrorHandler, 30000);5.4 防检测措施游戏可能会检测自动化脚本，一些防护措施：// 1. 随机延迟（最重要） function humanLikeDelay() { // 模拟人类操作的不规则延迟 let base = random(300, 800); // 偶尔来个长延迟，像是在思考 if (random(1, 10) === 1) { base += random(1000, 3000); } sleep(base); } // 2. 随机点击位置 function humanLikeClick(x, y, range = 10) { let actualX = x + random(-range, range); let actualY = y + random(-range, range); click(actualX, actualY); } // 3. 随机滑动速度 function humanLikeSwipe(x1, y1, x2, y2) { let duration = random(400, 800); swipe(x1, y1, x2, y2, duration); } // 4. 设置随机的运行时间，不要 24 小时挂机 function shouldTakeBreak() { // 每运行 1-2 小时休息一下 // ... }第六章：踩坑记录——血与泪的教训6.1 坑 1：无障碍服务被杀现象：脚本运行一段时间后自动停止原因：安卓系统的电池优化会杀后台应用解决：关闭 AutoX.js 的电池优化允许后台运行开启前台服务（悬浮窗）部分手机需要加入白名单6.2 坑 2：控件找不到现象：findOne() 返回 null可能原因：控件还没加载出来 → 加等待控件在 WebView 里 → 用 className("android.webkit.WebView") 先找到 WebView游戏用 Canvas 绘制 → 改用图色识别控件被遮挡 → 先关闭弹窗6.3 坑 3：click() 没反应现象：代码执行了但没效果可能原因：点击位置不对 → 用布局分析确认需要长按而不是点击 → 用 longClick()控件不可点击 → 点击其父控件有透明遮罩层 → 先处理遮罩 // 点击父控件 let target = text("目标").findOne(); if (target && !target.clickable()) { target.parent().click(); }6.4 坑 4：图片资源管理现象：运行一段时间后内存爆了原因：images.read() 和 captureScreen() 创建的图片没有释放解决：let img = captureScreen(); // ... 使用图片 img.recycle(); // 用完一定要 recycle！ // 或者用 images.copy() 复制一份 let copy = images.copy(img); img.recycle();第七章：最终效果与感悟7.1 成果展示经过两周的开发迭代，"猫吃鱼"实现了：✅ 自动领取所有奖励（邮件、任务、活动）✅ 自动完成日常任务（签到、免费抽卡）✅ 自动刷副本（支持配置次数）✅ 自动竞技场✅ 异常恢复（弹窗处理、游戏崩溃重启）✅ 运行日志和统计每天早上起床，打开脚本，等刷完牙洗完脸，日常任务已经做完了。时间节省：从每天手动操作 30-40 分钟，到现在只需要点一下"开始"。7.2 一些感悟自动化的本质是理解规律写脚本的过程，其实是在深入理解游戏机制。哪些操作是固定的？哪些是随机的？什么时候会弹窗？理解了这些，脚本才能稳定运行。程序员思维 vs 产品思维一开始我想把功能做得很全，后来发现 80% 的收益来自 20% 的功能。与其花大量时间做"完美"的脚本，不如先做好核心功能。边界情况永远比预想的多网络波动、游戏更新、意外弹窗... 真实环境比测试复杂得多。要有足够的异常处理和日志记录。关于游戏和脚本的思考用脚本"解放双手"后，我反而开始思考：如果一个游戏需要用脚本才能"好玩"，那它真的好玩吗？附录：常用 API 速查表控件操作// 查找控件 text("文字").findOne(timeout) textContains("部分文字").find() id("resource_id").findOne() desc("描述").findOne() className("android.widget.Button").find() // 组合查找 text("文字").className("Button").findOne() // 控件操作 widget.click() widget.longClick() widget.setText("文字") widget.bounds() // 获取位置 widget.parent() // 父控件 widget.children() // 子控件` ### 手势操作 javascript `click(x, y) longClick(x, y) swipe(x1, y1, x2, y2, duration) gesture(duration, [x1, y1], [x2, y2], ...) press(x, y, duration)图色操作requestScreenCapture() // 申请截图权限 captureScreen() // 截图 images.read(path) // 读取图片 findImage(img, template, options) // 找图 findColor(img, color, options) // 找色 images.pixel(img, x, y) // 获取像素颜色 img.recycle() // 释放资源应用操作app.launchPackage(packageName) // 启动应用 currentPackage() // 当前应用包名 currentActivity() // 当前 Activity back() // 返回键 home() // Home 键结语从一个想偷懒的念头，到一个能稳定运行的自动化脚本，"猫吃鱼"项目让我体会到了编程的乐趣——用代码解决实际问题。当然，这只是自动化入门。更复杂的场景可能需要：OCR 文字识别机器学习判断场景云控多设备管理...但对于一个咸鱼玩家来说，现在的"猫吃鱼"已经足够了。毕竟，真正的咸鱼，是让脚本替自己咸的。