搜 索

如何编写一个手机自动化脚本

  • 60阅读
  • 2025年12月27日
  • 0评论
首页 / 作品/创作 / 正文

前言:一个咸鱼玩家的觉醒

事情是这样的。

某天我下载了一款叫《咸鱼之王》的游戏。作为一个放置类游戏,它的核心玩法是——点点点

  • 每日任务要点
  • 领奖励要点
  • 打副本要点
  • 抽卡要点
  • 合成要点
  • ……

玩了一周后,我的手指已经开始抗议了。作为一个程序员,我陷入了深深的思考:

我玩游戏,还是游戏玩我?

于是,"猫吃鱼"项目诞生了——让脚本替我吃掉这条咸鱼。


第一章:技术选型——为什么是 Auto.js

1.1 安卓自动化方案对比

方案优点缺点
Auto.js无需 root,基于无障碍服务,JavaScript 语法官方版已下架,需要用开源版
Tasker功能强大,生态丰富学习曲线陡峭,自动化能力有限
ADB + Python灵活,可电脑控制需要连接电脑,不够便携
按键精灵简单易用功能有限,很多要付费
Root + 触摸模拟权限最高,啥都能干需要 root,有变砖风险

最终选择 Auto.js(准确说是开源版 AutoX.js),原因:

  1. 无需 root:基于安卓无障碍服务(Accessibility Service)
  2. JavaScript 语法:前端出身的我表示很亲切
  3. 开源免费:AutoX.js 是社区维护的开源版本
  4. 功能全面:控件操作、图色识别、悬浮窗、定时任务一应俱全

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:资源 ID
  • className:控件类型
  • bounds:位置和大小

技巧:优先用 textid,它们最稳定。


第三章:项目架构——"猫吃鱼"的设计

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(
    <vertical padding="16">
        <text text="🐱 猫吃鱼 - 咸鱼之王助手" textSize="22sp" textColor="#333333" gravity="center" marginBottom="16"/>
        
        <horizontal>
            <checkbox id="cbDaily" text="日常任务" checked="true"/>
            <checkbox id="cbBattle" text="自动副本" checked="true" marginLeft="16"/>
        </horizontal>
        
        <horizontal marginTop="8">
            <checkbox id="cbArena" text="竞技场" checked="false"/>
            <checkbox id="cbAd" text="看广告" checked="false" marginLeft="16"/>
        </horizontal>
        
        <horizontal marginTop="16">
            <text text="副本次数: "/>
            <input id="inputTimes" text="10" inputType="number" w="80"/>
        </horizontal>
        
        <horizontal marginTop="24" gravity="center">
            <button id="btnStart" text="开始运行" w="120" style="Widget.AppCompat.Button.Colored"/>
            <button id="btnStop" text="停止" w="100" marginLeft="16"/>
        </horizontal>
        
        <text text="运行日志:" marginTop="16"/>
        <scroll h="200">
            <text id="txtLog" textSize="12sp" textColor="#666666"/>
        </scroll>
        
        <text text="提示: 请先打开游戏,再点击开始运行" textSize="12sp" textColor="#999999" marginTop="8"/>
    </vertical>
);

// ==================== 全局变量 ====================
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:无障碍服务被杀

现象:脚本运行一段时间后自动停止

原因:安卓系统的电池优化会杀后台应用

解决

  1. 关闭 AutoX.js 的电池优化
  2. 允许后台运行
  3. 开启前台服务(悬浮窗)
  4. 部分手机需要加入白名单

6.2 坑 2:控件找不到

现象findOne() 返回 null

可能原因

  1. 控件还没加载出来 → 加等待
  2. 控件在 WebView 里 → 用 className("android.webkit.WebView") 先找到 WebView
  3. 游戏用 Canvas 绘制 → 改用图色识别
  4. 控件被遮挡 → 先关闭弹窗

6.3 坑 3:click() 没反应

现象:代码执行了但没效果

可能原因

  1. 点击位置不对 → 用布局分析确认
  2. 需要长按而不是点击 → 用 longClick()
  3. 控件不可点击 → 点击其父控件
  4. 有透明遮罩层 → 先处理遮罩

// 点击父控件
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 一些感悟

  1. 自动化的本质是理解规律
    写脚本的过程,其实是在深入理解游戏机制。哪些操作是固定的?哪些是随机的?什么时候会弹窗?理解了这些,脚本才能稳定运行。
  2. 程序员思维 vs 产品思维
    一开始我想把功能做得很全,后来发现 80% 的收益来自 20% 的功能。与其花大量时间做"完美"的脚本,不如先做好核心功能。
  3. 边界情况永远比预想的多
    网络波动、游戏更新、意外弹窗... 真实环境比测试复杂得多。要有足够的异常处理和日志记录。
  4. 关于游戏和脚本的思考
    用脚本"解放双手"后,我反而开始思考:如果一个游戏需要用脚本才能"好玩",那它真的好玩吗?

附录:常用 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 文字识别
  • 机器学习判断场景
  • 云控多设备管理
  • ...

但对于一个咸鱼玩家来说,现在的"猫吃鱼"已经足够了。

毕竟,真正的咸鱼,是让脚本替自己咸的

评论区
暂无评论
avatar