🧩 瑞莎 Radxa ROCK 5B+(RK3588)单板搭建高性能安卓游戏脚本机


🎯 1. 目标与预算

✅ 最终目标:

  • 设备搭载并稳定运行 Android 12 系统
  • 设备完整支持 Google 服务框架(GMS),可运行Google Play、Gmail等应用
  • 设备能够 流畅运行对图形和资源要求较高的大型安卓游戏,如《ARK: Ultimate Mobile Edition》、《原神》等
  • 设备可运行 内置自动化脚本,实现领取登录奖励,刷初始号等复杂任务
  • 设备可运行 自定义开机启动脚本,上电即运行预设逻辑或启动自动任务

💰 预算:

总预算上限:约¥2000


🛠️ 2. 硬件选型

为了顺利运行大型安卓手游,系统配置至少需满足以下标准:

  • 内存:≥ 8GB(推荐16GB)
  • 存储:≥ 250GB,需支持高速读写(读写速度至少每秒千兆级,避免加载卡顿)

🔍 采购配件:

  • Radxa ROCK 5B+ 主板套装(RK3588, 16GB RAM + 电源 + 散热风扇 + 金属外壳):¥1332
  • Radxa 原厂 8英寸 HD 触摸屏(800×1280 分辨率):¥366
  • SanDisk 250GB NVMe SSD(M.2 接口,读取2400MB/s,写入1500MB/s):¥219
  • SanDisk 64GB 高速 SD 卡 ×2(读取140MB/s):¥72

💡 小提示:适配 Radxa 安卓系统的触摸屏目前只能选官方出品的型号,亲测其它品牌基本无法兼容,基本没有选择空间。
此外,虽然 Radxa 提供了集成 eMMC 的主板套装,但相比之下,自行搭配高性能 SD 卡在价格和灵活性上更具优势。
至于为什么买了两张 SD 卡?后文会揭晓用途

📦 其他配件(若无则需额外采购):

由于系统从零开始部署,以下配件几乎必不可少:

  • SD 卡读卡器:用于烧录系统镜像,市场估价¥10-30
  • USB-Type A / Type C 数据线:用于连接主机进行 adb 调试,市场估价¥10-¥25
  • USB 转串口线(可选):排查启动失败或系统异常时的调试利器,市场估价¥15-60

🧠 3. 软件架构

💡 设计思路

Radxa ROCK 5B+ 支持从 SD 卡、eMMC 和 NVMe(M.2 接口)启动 Android 系统。虽然官方声称支持 NVMe 启动,但经过我的实测与技术咨询,系统实际上只能安装在采用 FAT32 文件系统的 NVMe 存储设备上。

但遗憾的是,市面上主流的大容量 SSD 通常预设为 NTFS 或 ext4 格式,不支持直接安装 Android 系统,也无法从中启动。因此,我采取以下方案:

  • 使用一张 64GB SD 卡 安装 Android 系统,作为启动盘;
  • 系统启动后再挂载一块 250GB NVMe SSD,作为游戏数据等大文件的存储盘。

此外,原计划是在系统内部运行自动化脚本。但经过测试发现,Radxa 官方提供的 Android 镜像为开发者调试版,仅支持 adb root不具备系统级 root 权限,因此无法单独通过单板运行自动化脚本。

因此,我采用外部自动化控制架构:通过 PC 上的脚本远程控制 Android 系统完成任务,具体如下:

  • PC 端:Python + OpenCV 图像识别 + Tesseract OCR 文字识别
  • 连接方式:ADB 调试连接
  • Android 端(Radxa ROCK 5B+):
    • SD 卡(64GB):用于安装 Android 系统
    • SSD 固态硬盘(250GB):用于存储游戏大数据包及频繁读写文件,以提升性能

🔧 性能优化策略:我将游戏的资源文件、贴图包等读写频繁的内容移至 SSD,以最大化运行效率。而用户数据、APK 文件等读取频率较低的数据保留在 SD 卡,兼顾性能与稳定性。

📊 架构图示意

在这里插入图片描述


🧱 4. 脚本平台搭建流程

🧩 4.1 烧录 Android 12 镜像至 SD 卡

  1. 下载 Radxa 官方推荐的烧录工具balenaEtcher-Setup-1.18.11.exe

  2. 下载 适用于 SD 卡启动的 Android 12 镜像压缩包Rock5BPlus-Android12-rkr14-SD-or-eMMC-20240705-gpt.zip(解压后得到 .img 镜像文件)。

  3. 将 SD 卡插入SD卡读卡器,并连接到 PC。

  4. 打开 balenaEtcher,按照界面提示选择 .img 文件和目标 SD 卡,开始烧录。

  5. 烧录完成后,不要格式化系统弹出的任何新磁盘分区(如 I:、K: 等)

⚠️ 注意:部分 SD 卡烧录完成后,Windows 会弹出多个格式化提示框。这些分区是 Android 系统的必要组成部分,
若误格式化其中任何一个分区,将导致系统无法正常启动

🔧 4.2 搭建 ADB 调试环境

  1. 将烧录好的 SD 卡插入 Radxa Rock 5B+。

  2. 将Nvme固态一段插入m.2接口另一端用螺丝固定

  3. 按照瑞莎官方的参考将单板和触摸屏通过排线链接

  4. 将单板和电源线链接,接通电源,系统将自动从 SD 卡启动进入 Android。

  5. 将单板用 USB Type-A 转 Type-C 数据线连接至 PC。

  6. 打开 PowerShell(或终端),执行:

    adb devices
    

    如果设备列表中出现 Rock 5B+ 的设备识别码(通常是一串随机的数字和字母组合),说明连接成功。

  7. 在 PC 上执行:

    adb shell
    

    然后尝试获取 root 权限:

    su
    

    成功后将进入 adb root 模式,可查看系统参数和配置。

  8. (可选)若有串口转 USB 数据线,可以按照瑞莎官方提供的接线方式将单板链接到 PC ,使用 PuTTY 或其他串口工具连接串口并查看启动日志(参考官方设置波特率)。

💽 4.3 SSD 固态硬盘挂载

1. 确认系统识别到 SSD

使用 adb 连接至设备终端,获取 root 权限后执行以下命令:

ls /dev/block

若输出中包含 nvme0n1,说明系统已成功识别到 NVMe 固态硬盘。

2. 检查是否已有分区表

由于官方镜像中未集成 vimfdisk,需借助 busybox 工具来查看磁盘状态:

busybox fdisk -l /dev/block/nvme0n1

若返回信息中含有:

Disk /dev/block/nvme0n1 doesn't contain a valid partition table

则说明该硬盘尚未创建分区,需要手动分区并格式化。

3. 使用 fdisk 创建分区

执行以下命令进入分区交互模式:

busybox fdisk /dev/nvme0n1

根据提示依次输入以下指令:

o   ← 新建空的 DOS 分区表  
n   ← 新建分区  
p   ← 创建主分区  
1   ← 分区号  
[Enter] ← 默认起始位置  
[Enter] ← 默认终止位置(使用整个磁盘)  
w   ← 写入并保存分区表
4. 格式化分区为 ext4 文件系统

执行以下命令格式化固态硬盘:

mkfs.ext4 /dev/block/nvme0n1p1

格式化成功后,在 /dev/block/ 下应能看到新的分区节点 nvme0n1p1

5. 手动挂载 SSD 到指定目录

执行以下命令挂载固态硬盘:

mkdir -p /mnt/nvme
mount -t ext4 /dev/block/nvme0n1p1 /mnt/nvme

可通过访问 /mnt/nvme 目录检查挂载状态与文件系统结构是否正常。

说明:格式化后显示容量约为 228GB 属正常现象。厂商标称的 “250GB” 使用的是十进制(1GB = 1,000,000,000 字节),而操作系统采用二进制(1GiB = 1,073,741,824 字节)进行换算,二者存在单位差异。

🧃 4.4 部署应用(以手游《The Wolf》为例)

这里轻量手游《The Wolf》为例,记录游戏部署流程

1. 下载安装包

一般游戏安装包都是.apk文件,可以直接使用adb install安装

但我这里下载的是.xapk文件,需要先进行解压处理:

  • .xapk 后缀修改为 .zip
  • 使用 WinRAR 或其他压缩工具解压。

解压后的文件通常包含:

  • 主 APK 包(例如:com.swiftappskom.thewolfrpg.apk);
  • 配置 APK 包(例如:config.arm64_v8a.apk);
  • 其他文件如 icon.pngmanifest.json(可忽略)。
2. 使用 ADB 安装游戏

如果是.apk文件,直接通过adb install安装

对于我下载的.xapk文件,解压后通过以下命令安装:

adb install-multiple com.swiftappskom.thewolfrpg.apk config.arm64_v8a.apk

注意这条命令的参数要先跟主 APK 包,再跟配置 APK 包。

3. 理解游戏安装路径结构

安装完成后,游戏相关文件通常分布在以下几个目录:

  • /data/app/:APK 安装位置;
  • /data/data/:应用运行时数据(如存档、账号信息);
  • /data/media/0/Android/data/:游戏下载的缓存和资源;
  • /data/media/0/Android/obb/:大型资源包(如地图、贴图);
  • /user_de/0/:部分延迟加载或用户解密数据。

重点:我们通常只迁移 /data/data 与 /data/media 目录的数据,因为其他目录里的文件不会影响游戏运行速度

4. 迁移游戏数据

通过以下命令将/data/data和/data/media目录的数据迁移到我们的SSD固态盘上(目标路径可以自己定):

cp -rp /data/data/com.swiftappskom.thewolfrpg /mnt/nvme/data
cp -rp /data/media/0/Android/data/com.swiftappskom.thewolfrpg /mnt/nvme/media/0/Android/data
cp -rp /data/media/0/Android/obb/com.swiftappskom.thewolfrpg /mnt/nvme/media/0/Android/obb
5. 通过 mount --bind 实现游戏数据迁移

编写 nvme_bind.sh 脚本,先挂载nvme然后再将游戏数据目录绑定到外部硬盘(如 NVMe):

#!/system/bin/sh

# mount nvme
mkdir -p /mnt/nvme
mount -t ext4 /dev/block/nvme0n1p1 /mnt/nvme

# bind game data
rm -rf /data/data/com.swiftappskom.thewolfrpg
mkdir /data/data/com.swiftappskom.thewolfrpg
mount --bind /mnt/nvme/data/com.swiftappskom.thewolfrpg /data/data/com.swiftappskom.thewolfrpg

rm -rf /data/media/0/Android/data/com.swiftappskom.thewolfrpg
mkdir /data/media/0/Android/data/com.swiftappskom.thewolfrpg
mount --bind /mnt/nvme/media/0/Android/data/com.swiftappskom.thewolfrpg /data/media/0/Android/data/com.swiftappskom.thewolfrpg

rm -rf /data/media/0/Android/obb/com.swiftappskom.thewolfrpg
mkdir /data/media/0/Android/obb/com.swiftappskom.thewolfrpg
mount --bind /mnt/nvme/media/0/Android/obb/com.swiftappskom.thewolfrpg /data/media/0/Android/obb/com.swiftappskom.thewolfrpg

将脚本保存到 /data/local/nvme_bind.sh 并赋予执行权限:

chmod +x /data/local/nvme_bind.sh

每次开机后手动运行此脚本,以挂载游戏数据目录。之后就可以正常运行游戏。

⚠️ 注意:这里不能直接挂载整个/data/media目录,否则会导致挂载点被覆盖,游戏找不到相应数据包的路径

🌼 4.5 安卓环境优化

1. 设置系统语言为中文

打开系统设置 → 【System】(系统)→ 【Languages & input】(语言和输入法)→【Languages】(语言)。

点击【Add a language】,选择中文(简体)。

添加后,长按“中文(简体)”并拖动至列表最上方,使其成为系统默认语言。

2. 设置系统时间与时区

进入系统设置 → 系统 → 日期与时间

点掉自动设置时区

点击选择时区,点击区域选择“中国”,点开时区选择“上海”

3. 更换 Launcher(启动器)

默认启动器功能简陋,建议替换为功能完整的 Nora Launcher

在 PC 端下载 Nora Launcher APK,并使用 ADB 安装:

adb install NoraLauncher.apk

安装完成后,进入系统设置 → 应用 → 默认应用 → 主屏幕应用,将其更改为 “Nora Launcher”。

启动 Nora Launcher 后,从屏幕右侧的边栏(或底部应用抽屉)打开应用列表,长按应用图标,即可将其拖动至主屏幕以创建快捷方式。

👓 4.6 脚本编写

1. 手动触摸屏检查

在设备上用手进行点按、滑动等操作,同时在 PC 端执行如下命令实时查看输入事件:

adb shell getevent -lt

若能观察到 /dev/input/event* 的时间戳与触摸事件,说明触摸屏事件系统正常工作。

2. 使用 ADB 命令模拟输入操作

常用 ADB 输入命令:

  • 点按指定坐标:adb shell input tap <x> <y>
  • 滑动操作:adb shell input swipe <x1> <y1> <x2> <y2> <duration_ms>
  • 输入文本:adb shell input text "hello"
  • 返回键 / Home 键等控制:adb shell input keyevent KEYCODE_BACKadb shell input keyevent KEYCODE_HOME

若执行后屏幕有对应操作反馈,说明 ADB 控制功能正常。

3. 编写Python工具包

在确认触控和adb调试命令都没有问题以后,就可以开始Python工具包的编写。

因为不是什么很复杂的架构,所以就不写继承类特性类之类的py文件了。

先编写 adb_debug.py(英文注释防止乱码):

import subprocess
import re
import sys
import os
import datetime
import argparse

import adb_util

# Maximum touch coordinates (based on `getevent -p`)
touch_x_max = 800
touch_y_max = 1280

def get_screen_size():
    """
    Get the physical screen resolution of the connected Android device.

    Returns:
        tuple[int, int]: Width and height of the screen in pixels.
    """
    result = subprocess.check_output(['adb', 'shell', 'wm', 'size']).decode()
    for line in result.splitlines():
        if 'Physical size:' in line:
            res = line.split(':')[1].strip()
            width, height = map(int, res.split('x'))
            return width, height
    raise RuntimeError("Unable to get screen resolution")

def to_physical(x, y):
    """
    Convert touch coordinates from getevent to physical screen coordinates.

    Args:
        x (int): Raw touch X coordinate.
        y (int): Raw touch Y coordinate.

    Returns:
        tuple[int, int]: Mapped screen coordinates.
    """
    screen_w, screen_h = get_screen_size()
    physical_x = int(x / touch_x_max * screen_w)
    physical_y = int(y / touch_y_max * screen_h)
    return physical_x, physical_y

def start_touch_probe():
    """
    Listen to raw touchscreen events and print translated screen coordinates.
    """
    pattern = re.compile(r"ABS_MT_POSITION_(X|Y)\s+([0-9a-f]+)")

    proc = subprocess.Popen(
        ["adb", "shell", "getevent", "-lt", "/dev/input/event1"],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        universal_newlines=True
    )

    print("Listening for touch coordinates... (Ctrl+C to stop)")

    current_x = None
    current_y = None

    try:
        for line in proc.stdout:
            match = pattern.search(line)
            if match:
                axis = match.group(1)
                hex_value = match.group(2)
                dec_value = int(hex_value, 16)

                if axis == "X":
                    current_x = dec_value
                elif axis == "Y":
                    current_y = dec_value

                if current_x is not None and current_y is not None:
                    phys_x, phys_y = to_physical(current_x, current_y)
                    print(f"X={phys_x}, Y={phys_y}")
    except KeyboardInterrupt:
        print("Stopped.")
        proc.terminate()

def take_screenshot(save_dir="."):
    """
    Capture the device screen and save the image to the local machine.

    Args:
        save_dir (str): Local directory to store the screenshot (default: current directory).
    """
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    local_filename = f"screenshot_{timestamp}.png"
    local_path = os.path.join(save_dir, local_filename)

    try:
        subprocess.run(["adb", "shell", "screencap", "-p", "/sdcard/screen.png"], check=True)
        subprocess.run(["adb", "pull", "/sdcard/screen.png", local_path], check=True)
        subprocess.run(["adb", "shell", "rm", "/sdcard/screen.png"], check=True)
        print(f"Screenshot saved to: {local_path}")
    except subprocess.CalledProcessError as e:
        print(f"Error taking screenshot: {e}")

def get_foreground_app(target_package=None):
    """
    List all current window activities or filter by a specific package name.
    """
    try:
        output = subprocess.check_output(
            ["adb", "shell", "dumpsys", "window", "windows"],
            universal_newlines=True
        )

        # Match every style which like Window{... com.package.name/com.activity.Name}
        pattern = re.compile(r"Window\{[^\}]*?\s+(\S+)/(\S+)}")
        found = set()

        for match in pattern.finditer(output):
            pkg, act = match.groups()
            if (target_package is None) or (pkg == target_package):
                entry = f"{pkg}/{act}"
                if entry not in found:
                    found.add(entry)

        if found:
            print("Found activity windows:")
            for entry in sorted(found):
                print(f"  {entry}")
        else:
            print("No matching activity found.")

    except subprocess.CalledProcessError as e:
        print(f"Failed to execute adb command: {e}")

def screen_freeze():
    adb_util.adb_keyevent("power")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="ADB Debug Tools")

    parser.add_argument(
        "mode",
        choices=["touch", "shot", "fore", "freeze"],
        help=(
            "Select the operation mode: touch, shot, fore or freeze\n"
        )
    )

    args = parser.parse_args()

    if args.mode == "touch":
        start_touch_probe()
    elif args.mode == "shot":
        take_screenshot()
    elif args.mode == "fore":
        get_foreground_app()
    elif args.mode == "freeze":
        screen_freeze()

说明:
get_screen_size: 用于获取屏幕真实分辨率的函数
to_physical: 用于点击坐标转换的函数
start_touch_probe: 用于开启监听记录屏幕点按坐标的函数
take_screenshot: 用于游戏截图并保存到PC端的函数
get_foreground_app: 用于获取游戏包名和活动名的函数,配合adb_util.py中的start_app可以做到利用脚本打开游戏
screen_freeze: 用于锁频的函数

再编写 adb_util.py(英文注释防止乱码):

import subprocess
import time
import cv2
import numpy as np

touch_x_max = 1920
touch_y_max = 1080
touch_y_min_valid = 350
touch_y_max_valid = 730

def adb_delay(seconds):
    """
    Wait for a given number of seconds.

    Args:
        seconds (float): Number of seconds to sleep.
    """
    print(f"Sleeping for {seconds} seconds...")
    time.sleep(seconds)

def adb_tap_vertical(x, y):
    """
    Simulate a tap on screen coordinates for portrait mode.
    Converts portrait (x, y) to landscape raw input.

    Args:
        x (int): X coordinate in portrait mode (0-1080).
        y (int): Y coordinate in portrait mode (0-1920).
    """
    print(f"Tapping at: ({x}, {y})")
    phys_x = int((x / 1920) * 1080)
    phys_y = int((y / 1080) * 1920)
    subprocess.run(['adb', 'shell', 'input', 'tap', str(phys_x), str(phys_y)])

def adb_tap(x, y):
    """
    Simulate a tap on screen coordinates.

    Args:
        x (int): X coordinate.
        y (int): Y coordinate (mapped to a valid screen range).
    """
    print(f"Tapping at: ({x}, {y})")
    y_ratio = (y - touch_y_min_valid) / (touch_y_max_valid - touch_y_min_valid)
    mapped_y = int(y_ratio * touch_y_max)
    subprocess.run(['adb', 'shell', 'input', 'tap', str(x), str(mapped_y)])

def adb_keyevent(key_name):
    """
    Send a key event to the device.

    Args:
        key_name (str): One of the supported key names like 'home', 'back', etc.
    """
    key_map = {
        "home": "KEYCODE_HOME",
        "back": "KEYCODE_BACK",
        "recent": "KEYCODE_APP_SWITCH",
        "power": "KEYCODE_POWER",
        "volume_up": "KEYCODE_VOLUME_UP",
        "volume_down": "KEYCODE_VOLUME_DOWN"
    }
    if key_name not in key_map:
        raise ValueError(f"Unknown key name: {key_name}")
    subprocess.run(['adb', 'shell', 'input', 'keyevent', key_map[key_name]])

def tap_sequence_vertical(coord_list, delay=3):
    """
    Perform a sequence of taps with delay in between.

    Args:
        coord_list (list of tuple): List of (x, y) coordinates.
        delay (float): Delay in seconds between each tap.
    """
    for x, y in coord_list:
        adb_tap_vertical(x, y)
        adb_delay(delay)

def tap_sequence(coord_list, delay=3):
    """
    Perform a sequence of taps with delay in between.

    Args:
        coord_list (list of tuple): List of (x, y) coordinates.
        delay (float): Delay in seconds between each tap.
    """
    for x, y in coord_list:
        adb_tap(x, y)
        adb_delay(delay)

def start_app(package_name, activity_name):
    """
    Start an Android app using ADB with am start.

    Args:
        package_name (str): Package name of the app.
        activity_name (str): Full activity name (e.g., .MainActivity or com.package/.MainActivity).
    """
    cmd = ['adb', 'shell', 'am', 'start', '-n', f'{package_name}/{activity_name}']
    try:
        result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
        print("Output:\n", result.stdout)
    except subprocess.CalledProcessError as e:
        print("Error:\n", e.stderr)

def force_stop(package_name):
    """
    Force-stop the given Android app via ADB.

    Args:
        package_name (str): The package name of the app to stop.
    """
    print(f"Force-stopping app: {package_name}")
    subprocess.run(['adb', 'shell', 'am', 'force-stop', package_name])

def adb_swipe_vertical(start_x, start_y, end_x, end_y, duration_ms=300):
    """
    Perform a swipe gesture from start point to end point, mapping Y values.

    Args:
        start_x (int): Start X coordinate.
        start_y (int): Start Y coordinate (to be mapped).
        end_x (int): End X coordinate.
        end_y (int): End Y coordinate (to be mapped).
        duration_ms (int): Duration of the swipe in milliseconds.
    """
    mapped_start_x = int((start_x / 1920) * 1080)
    mapped_start_y = int((start_y / 1080) * 1920)
    mapped_end_x = int((end_x / 1920) * 1080)
    mapped_end_y = int((end_y / 1080) * 1920)

    print(f"Swiping from ({mapped_start_x}, {mapped_start_y}) to ({mapped_end_x}, {mapped_end_y})")
    subprocess.run([
        'adb', 'shell', 'input', 'swipe',
        str(mapped_start_x), str(mapped_start_y),
        str(mapped_end_x), str(mapped_end_y),
        str(duration_ms)
    ])
    adb_delay(3)

def adb_swipe(start_x, start_y, end_x, end_y, duration_ms=300):
    """
    Perform a swipe gesture from start point to end point, mapping Y values.

    Args:
        start_x (int): Start X coordinate.
        start_y (int): Start Y coordinate (to be mapped).
        end_x (int): End X coordinate.
        end_y (int): End Y coordinate (to be mapped).
        duration_ms (int): Duration of the swipe in milliseconds.
    """
    start_y_ratio = (start_y - touch_y_min_valid) / (touch_y_max_valid - touch_y_min_valid)
    end_y_ratio = (end_y - touch_y_min_valid) / (touch_y_max_valid - touch_y_min_valid)

    mapped_start_y = int(start_y_ratio * touch_y_max)
    mapped_end_y = int(end_y_ratio * touch_y_max)

    print(f"Swiping from ({start_x}, {mapped_start_y}) to ({end_x}, {mapped_end_y})")
    subprocess.run([
        'adb', 'shell', 'input', 'swipe',
        str(start_x), str(mapped_start_y),
        str(end_x), str(mapped_end_y),
        str(duration_ms)
    ])
    adb_delay(3)

def adb_input_text(text):
    """
    Input text on the connected Android device.

    Limitations:
        - Cannot contain: " or '
        - Spaces will be replaced with '%s'
        - &, |, (, ) will be escaped to prevent shell errors

    Args:
        text (str): Text string to input.
    """
    print(f"Inputting text: {text}")
    # Escape problematic characters for ADB shell input
    safe_text = text.replace(' ', '%s').replace('&', '\&').replace('|', '\|').replace('(', '\(').replace(')', '\)')
    subprocess.run(['adb', 'shell', 'input', 'text', safe_text])

def match_template_on_screen(template_path, threshold=0.94):
    """
    Capture the device screen and check if a template image exists on it.

    Args:
        template_path (str): Path to the local template image file (e.g., PNG).
        threshold (float): Match threshold between 0 and 1. Default is 0.95.

    Returns:
        bool: True if template is found with sufficient match, False otherwise.
    """
    print("Capturing screen...")
    try:
        # Get screenshot as bytes
        result = subprocess.run(['adb', 'exec-out', 'screencap', '-p'], stdout=subprocess.PIPE, check=True)
        screenshot_bytes = result.stdout

        # Convert bytes to numpy array
        image_array = np.frombuffer(screenshot_bytes, np.uint8)
        screenshot = cv2.imdecode(image_array, cv2.IMREAD_COLOR)

        template = cv2.imread(template_path, cv2.IMREAD_COLOR)
        if template is None:
            raise FileNotFoundError(f"Template image not found: {template_path}")

        # Perform template matching
        res = cv2.matchTemplate(screenshot, template, cv2.TM_CCOEFF_NORMED)
        min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)

        print(f"Match value: {max_val:.4f}")
        return max_val >= threshold
    except Exception as e:
        print(f"Template matching failed: {e}")
        return False

说明:
adb_delay: 加了打印信息的延时函数
adb_tap_vertical: 用于竖屏游戏的点按函数,配合adb_debug.py的start_touch_probe使用,映射关系从start_touch_probe到adb_tap_vertical为X:(0-1920)->(0-1080), y:(0-1080)->(0-1920)
adb_tap: 用于大多数横屏游戏的点按函数,配合adb_debug.py的start_touch_probe使用,映射关系从start_touch_probe到adb_tap_vertical为X:(0-1920)->(0-1920), y:(350-710)->(0-1080)
adb_keyevent: 用来调用返回键,锁屏键等系统按键函数
tap_sequence_vertical: 用于竖屏游戏连续点击函数
tap_sequence: 用于大多数横屏游戏连续点击函数
start_app: 用来打开游戏的函数
force_stop: 用于强制停止游戏的函数
adb_swipe_vertical: 用于竖屏游戏滑动操作的函数
adb_swipe: 用于大多数横屏游戏滑动操作的函数
adb_input_text: 用于切换账号时输入文本的函数
match_template_on_screen: 用于对比游戏界面和截图的函数

4. 编写python脚本

有了之前的两个工具包之后,就能够编写python脚本了。
但在这之前需要先搭建工程的目录结构,毕竟所有文件堆在一起很难维护:

game
├── __pycache__
│   └── adb_util.cpython-310.pyc
├── adb_debug.py
├── adb_util.py
└── wolf
    ├── reward.png
    └── wolf.py

然后再一边调试一边编写脚本,这里拿一个最简单的《The Wolf》登录领取奖励的脚本为例:

import subprocess
import argparse
import inspect
import os
import sys

current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.join(current_dir, ".."))
import adb_util

package_name="com.swiftappskom.thewolfrpg"
activity_name="com.google.firebase.MessagingUnityPlayerActivity"

def start_game():
   # start game and wait for loading 
   adb_util.start_app(package_name, activity_name)
   adb_util.adb_delay(35)
   adb_util.adb_tap(998,701)
   adb_util.adb_delay(7)

def end_game():
   # end game
   adb_util.force_stop(package_name)
   adb_util.adb_delay(5)

def check_reward():
   # check if daily reward is appear
   img_path = os.path.join(current_dir, "reward.png")
   res= adb_util.match_template_on_screen(img_path)
   if res:
       adb_util.adb_tap(1564, 387)
       adb_util.adb_delay(3)
       adb_util.adb_tap(1276, 426)
       adb_util.adb_delay(3)
       adb_util.adb_tap(420, 648)
       adb_util.adb_delay(3)
       adb_util.adb_tap(986, 675)
       adb_util.adb_delay(25)
       adb_util.adb_tap(1825, 373)
       adb_util.adb_delay(3)
       adb_util.adb_tap(1825, 373)
       adb_util.adb_delay(5)
   else:
       adb_util.adb_delay(5)

def do_daily():
   start_game()
   check_reward()
   end_game()

if __name__ == "__main__":
   parser = argparse.ArgumentParser(description="Automatically game task runner")
   parser.add_argument(
       "mode",
       choices=["daily"],
       help="choose task mode:daily"
   )
   args = parser.parse_args()

   if args.mode == "daily":
       do_daily()

start_gamecheck_reward中的延时是通过自己的游戏经验判断网络可能造成的干扰,估算出来的,需要后期不断的调试来达到最佳值
start_gamecheck_reward中的点按坐标是根据adb_debug.py工具包的start_touch_probe获取的
start_gameend_game中的游戏包名和应用名是根据adb_debug.py工具包的get_foreground_app获取的

脚本可以通过python命令直接运行:

python3 wolf.py daily

⚠️ 注意:要成功运行python脚本进行自动化控制需要确保 PC 中有安装了 OpenCV 插件的 Python 环境


⚠️ 5. 注意事项

🔁 绑定顺序限制

Android 系统在安装应用时不会读取提前绑定的目录。如果你在执行 adb install 前已通过 mount --bind 映射路径,系统会中断绑定并将 APK 安装到默认目录,导致绑定失效。因此,在迁移应用数据的时候必须遵循以下顺序:

  1. 使用系统原生目录完成应用安装;
  2. 安装完成后再使用 mount --bind 映射到外部存储路径。

尽管这样的部署流程十分繁琐,但目前没有别的办法。若能在 Android 镜像 源码中修改应用安装的默认路径,将更好的处理这个问题。

🧠 游戏设备识别异常

某些游戏(如《The Wolf》)在运行时会将 Radxa Rock 5B+ 识别为模拟器,并强制账号进入“模拟器服”。这是由于开发板缺乏标准手机硬件标识(如 IMEI、基带信息等)所致。

要避免这种识别行为,必须修改 Android 镜像源码 ,为设备添加完整的硬件信息模拟。

🚫 无法获取 Root 权限

我尝试使用 Magisk 替换 boot.img 获取 Root 权限,但最后失败,导致系统无法正常启动。个人认为问题主要在于:

  • Magisk 无法识别开发板提供的 bootloader 文件格式;
  • 系统启动引导失败,需重新刷写镜像恢复。

这说明当前设备并不完全兼容 Magisk 的常规 Root 方案。

⚙️ 自定义开机脚本无法运行

经过我测试,/system, /etc, /vendor 这三个系统文件夹默认全是只读形式,即使是adb root也无法修改。

这直接导致了自定义开机脚本无法运行。

虽然可以利用adb root重新挂载vendor为可读写,但是依然存在种种限制,只能运行功能有限的脚本(比如挂载nvme)

只有修改 Android 镜像源码 重新编译后在烧写镜像才能实现自定义脚本开机运行的功能

📵 无法安装谷歌服务

我尝试以下多种方式安装 Google 服务均失败:

  • 使用 Go 安装器;
  • 手动提取并安装 Open GApps 中的 APK 包;

初步判断为镜像未提供必要依赖或权限配置,可能需要重新编译 Android 镜像源码 以支持 Google 框架

🤖 自动化全局脚本无法内部运行

我在尝试部署自动化控制脚本时发现,仅能通过 PC 端 adb root 的方式运行全局脚本,而无法在单板机内部独立运行全局脚本。

个人认为主要原因是 Android 的沙箱机制限制了应用之间的交互和全局文件访问权限;

因此,暂时只能使用外部主机通过 adb 接口远程调度任务。

若要在本地独立执行自动化脚本,需在 Android 镜像源码 中构建专属执行环境,并赋予必要的系统权限。

💻 触摸屏显示异常

Radxa 的单板只能适配 Radxa 提供的触摸屏,但是 Radxa 的触摸屏连接装了 Radxa 官方安卓镜像的 Radxa Rock 5B+ 单板后会出现显示异常,横屏只能显示1/3个屏幕。

可以通过 设置->显示->屏幕旋转->90 的方法转换成竖屏全屏显示,但打开横屏游戏后又会强制变回1/3个屏幕。

Radxa 提供的设置命令 adb shell setprop persist.sys.rotation.einit-1 1 也无法实现横屏全屏显示

在和 Radxa 客服进行多轮沟通后,基本上确定 Radxa 的技术人员把这个横屏只显示1/3个屏幕的 BUG 写死在了 Android 镜像源码 中,需要修改镜像源码才能实现横屏的全屏显示。另外这个BUG还搞得实际坐标和相对坐标很乱,XY坐标都是反的,导致脚本非常不好写,我直到现在还没搞清楚这个坐标转换的规律

在这里插入图片描述

💔 存储空间异常无法打开应用

有的游戏在完成数据迁移到SSD固态以后再次打开会出现 Error: Not enough storage space to install required resources 的报错。
如果出现这种情况,需检查是否绑定了/data/media或者/data/data整个目录。
如果绑定了整个目录,会导致覆盖系统原始挂载点,所以需要把挂载点精确到每个数据包,比如按照/data/media/0/Android/data/com.nhnent.SKQUEST这样一个一个数据包来挂载

💥 登录即闪退

经过我测试有个别游戏在完成mount --bind后打开登录即闪退,暂时不清楚是什么原因,估计是应用的代码里添加了对目录的特殊校验。
经过我进一步测试发现这些游戏只要不去mount --bind /data/data里面的数据包就不会触发校验,其他的目录里的数据可以正常迁移。
/data/data 里面的数据只能暂时让他在SD卡上运行了(暂时没找到什么好的解决方法)


🧩 6. 后记

我之前一直接触的都是 Linux 系统,这是我第一次接触 Android 系统,给我的整体感觉就是两个字“高贵”。

本来以为开发板一定会有系统的最高权限,系统的所有文件都能修改,但是 Android 12 的系统就是特别“高贵”。

什么都不能改,什么都不能动,不仅是系统文件不能改,哪怕是放应用文件的 /data 目录,也会被沙箱隔离。

据说谷歌这么做是为了安全,很好,你往 Android 普通版里放这些可以理解,但你往 Android 开发调试版里塞这些 🐔🎱 玩意这不是欠揍吗?

我前后调试了半个月,越调越💢,越改越😡。不得不承认 Android 12 系统很好的锻炼了我的心态。

还有那个模拟器识别,《The Wolf》还算好了还让你进模拟器服,像《Nikke》和《Action Taimanin》识别到模拟器直接不让进游戏。

人家花2000多买个高配平板,上手直接用,我花2000多买个组装开发板,组装完还被识别成模拟器,我可真 🌿🍋🐴🌩️

最后还有那个屏幕的坐标映射也搞得我很💥🛡️,他要是全部应用都是1/3屏幕显示还好,他竖屏应用能全屏显示,横屏应用1/3屏显示,而且全屏显示和1/3屏显示用的是两套不同的映射方案。而且这两个映射方案都非常奇葩。竖屏的映射方案是把屏幕窄的那条边映射到1920像素,长的那条边映射到1080像素,XY轴直接反转;横屏的映射方案是在竖屏的基础上,Y轴映射再以中心为原点,“缩水”1/3(从0-1080缩到350-710),我是测的差点就摔板子了,真的是大💥特💥了。

就算是在我的极力挽救下,也只是成功让这块开发板把 Android 12 系统运行起来,能通过adb链接PC来跑Python自动化脚本任务而已,写在开头的预期的目标几乎全军覆没。

回顾第五节总结的问题,大多数都和镜像源码有关。Android 12 (也可能是 Radxa 的技术人员)把太多的权限都写死在镜像源码中了,如果不能修改镜像源码,那就无法拥有 Android 12 系统的所有权限。

但是镜像源码修改了以后还得编译,而谷歌官方给出的编译环境最低条件就是16GB内存+250GB空间的 Ubuntu20.04 版本,我的PC完全满足不了这个要求(谁 🗼🐴 PC用Ubuntu系统?还16G内存?)。

不过这时我恰巧发现,我的 Rock 5B+ 不就是16GB内存+256GB空间的吗?于是我有了一个大胆的想法:如果我再买一张SD卡,烧写Ubuntu的镜像,放到 Rock 5B+ 里让他跑 Ubuntu 系统,那 Rock 5B+ 不就是一台小型编译服务器了吗?(开头的第二张SD卡就是这么来的)

而且 Rock 5B+ 自己编译出来的镜像给自己用,连交叉编译都省下了。

所以接下来我计划安装 Rock 5B+ 的 Linux 系统,在 Linux 系统中搭建安卓镜像的编译环境, 修改并编译瑞莎官方提供的 Android 镜像源码,最后将镜像装回 Rock 5B+ 进行调试

如果成功了会另开一篇博客来记录,失败了大家就当无事发生吧。


📚 参考资料

瑞莎 Radxa 5B+ 官方文档

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐