【杰哥带你玩转Android自动化】学穿:ADB

10,311 阅读13分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

0x1、引言

Hi,我是杰哥,在上一节的《开篇漫谈》中提到这样一句话:

所有Android自动化框架和工具中 操作Android设备的功能实现 都基于 adb无障碍服务AccessibilityService

本节我们来学习下前者,学习路线安排如下:

  • 简单了解下ADB的概念,是什么?由哪几部分组成?工作原理是咋样的?
  • ADB环境配置,以及两个常见问题的解决(adb端口占用,adb devices无法识别设备)
  • 学习ADB命令的语法,掌握一些自动化操作中巨常用的ADB命令;
  • 了解如何在Android App中执行adb命令;
  • 学习在PC端编写Python程序调用adb命令;
  • 实战:某办公软件打卡自动化;

就不废话水字数了,我直接开始~


0x2、ADB概念

① ADB是什么

ADB (Android Debug Bridge) ,译作 安卓调试桥,一个能让你 与Android设备进行通信命令行工具

说人话就是:你可以通过它,在命令行输入命令控制Android设备。


② ADB架构

ADB是一种C/S架构的应用程序,由三个部分组成:

  • 服务端PC端的adb server → 运行在PC端的后台进程,用于:
  • 检测USB端口感知设备连接与拔除;
  • 模拟器实例的启动与停止;
  • 将adb client的请求通过usb或tcp的方式发送到对应的adbd进程;
  • 客户端PC端的adb client → 主要用于发送命令
  • 解析像:push、shell、install等命令的参数,做必要预处理,然后转移为指令或数据,发送给adb server;
  • 守护进程手机端的adbd → 由init进程启动
  • 处理来自 adb server的命令行请求,获取对应Android设备的信息,再将结果返回给adb server;

工作原理

  • 启动adb客户端 → 检查是否有 adb服务端进程 在运行 → 没有的话启动一个;
  • adb服务端进程启动后会 与本地TCP端口5037绑定,并 监听adb客户端发出的命令
  • 服务端扫描 5555-5585 之间的 奇数端口 查找设备/模拟器,一旦发现 adbd进程 便会与相应端口建立连接;
  • 注:每个adbd会占用两个PC端口,奇数 用于 adb连接偶数 用于 命令行连接,如5554和5555端口是一对;
  • adb服务端与所有设备均建立连接后,你便能使用adb命令访问这些设备;

通信流程

对于原理和过程,大概了解,心理有数就行,看不懂也不影响你后面的学习。当然如果你对更深层的原理或源码感兴趣,可以参考下《adb和adbd分析》,笔者就不往下卷了~


0x3、ADB环境配置

如果您是 尊贵的Android开发,使用 Android Studio,并配置过环境变量,就不用再配了。可以直接打开命令行/终端键入 adb version 验证:

可以看到输出了:adb的版本信息可执行文件所在的路径

如果您不是Android开发也不打紧,到 官网 下个 SDK Platform-Tools

下载完成解压后可以看到:adb、fastboot等工具包

接着复制下文件夹路径,在 系统环境变量PATH 中加上它,然后就可以在命令行里直接使用adb啦~

同样键入 adb version 验证:

可以看到adb版本信息和可执行文件的路径都发生了改变,说明配置生效。


常见问题一:adb端口被占用

一般发生在Windows系统,如果你电脑安装了一些 XX手机助手 的软件,那在执行adb命令时,很大概率会遇到这个问题:

说明你的:5037端口被占用了,上面说过这个端口是留给adb server使用的,解决方法有两种:

方法一干掉占用进程 (建议)

键入:netstat -ano | findstr "5037",获取 占用端口的进程PID,如:

C:\Users\xxx>netstat -aon|findstr 5037
 TCP    127.0.0.1:5037         0.0.0.0:0              LISTENING       2908

键入:tasklist /fi "PID eq 2908",查看 进程PID对应的进程,如:

C:\Users\xxx>netstat -aon|findstr 5037

映像名称                       PID 会话名              会话#       内存使用
========================= ======== ================ =========== ============
xxx.exe                       2908 Console                    1     11,292 K

键入:taskkill /pid 2908 /f杀掉占用端口的进程,如:

C:\Users\xxx>taskkill /pid 2908 /f
成功: 已终止 PID 为 2908 的进程。

最后键入adb相关命令,如 adb devices,即可启动adb server进程,效果图如下:


方法二修改adb server端口

总有一些毒瘤进程,可能刚干死又重启了,方法一不一定能生效,打不过,躲得过,除了把毒瘤应用卸载外,还可以考虑下修改 adb server的端口号

建议选一个 五位的端口号(10000-65535),没那么容易重复,接着在 系统环境变量 中点击新建环境变量,变量名为 ANDROID_ADB_SERVER_PORT 变量值为端口号,如:

此时关掉命令行再次打开进入adb命令,可以看到端口号已经修改为10024了:

如果不是因为毒瘤,纯粹想改下端口,可以键入 adb kill-server 把adb server干掉,配置完成后,再键入 adb start-server 启动 adb server。

Tips:Linux、Mac系统直接终端输入 export $ANDROID_ADB_SERVER_PORT = 自定义端口 即可设置。


常见问题二:adb devices无法识别设备

问题描述:手机连上电脑,键入adb devices,却没输出任何设备?

回答:确定手机 USB调试 开了吗?开启方法如下 (不同手机系统可能存在差异,可自行搜索关键字):

首次连接,点击手机 设置系统信息 → 点击版本号多次直至出现 您已处于开发者模式 → 返回找到 开发者模式 → 找到 USB调试 开启,然后手机一般会弹个 授权的对话框,授权就好。此时再键入adb devices看看设备是否显示。

当然,如果你在授权的时候,手滑点了拒绝,此时键入adb devices时,设备的状态会显示为 unauthorized,再次拔插手机,授权窗口都不会再弹了,解决方法如下:

  • adb kill-server 关掉adb服务,拔掉手机;
  • 找到并删除电脑中的两个配置文件:/用户名/.android/adbkey/用户名/.android/adbkey.pub
  • adb start-server 启动adb服务,再插手机,授权弹窗应该就出来了;

如果使用了上述方法还是无法识别,那可能是 USB接口的问题驱动问题,可以 换个手机或者换条线 试试,如果正常说明不是USB接口问题。手机官网搜下对应手机型号的驱动,安装后试试。另外,重启试试 有时也包治百病~


0x4、ADB命令详解

adb完整命令语法如下

adb [-d|-e|-s <serialNumber>] <command>

如果 只有一个设备/模拟器连接PC,不用加中括号里的参数,当有多个时,才需要通过这些参数 指定目标设备

  • -d → 指定当前唯一通过usb连接的Android设备为命令目标;
  • -e → 指定当前唯一运行的模拟器为命令目标;
  • -s serialNumber → 指定对应serialNumber号的设备/模拟器为命令目标,最常用

上面 adb devices 输出的 8c8f689e 就是我当前连接的手机 序列号,除了真机还有 模拟器无线连接设备,如:

emulator-5554	device
10.129.164.6:5555	device

另外,adb命令 区分权限,有些命令需要 root权限 才能执行!

如果你手机已经Root了,想给adbd授予Root权限,下述方法二选一:

  • ① 键入 adb root,如果正常输出 restarting adbd as root,键入 adb shell
  • ② 键入 adb shell,输入 su

当然如果想取消adbd的root权限,也可以键入 adb unroot

对了,有些手机即使Root了,也可能无法让adbd以root权限执行,如三星的部分机型,会提示 adbd cannot run as root in production builds,可以先安装 adbd Insecure,然后再次尝试。

接着罗列笔者觉得自动化操作最常用的命令,更多命令可到 ADB官方文档mzlogin/awesome-adb 自行查阅~

① 查看前台Activity

命令:(可以借此拿到应用包名和当前Activity名)

# 进入Android终端的命令行模式
adb shell 

# 输出前台Activity
dumpsys activity activities | grep mResumedActivity

结果

② 启动应用/调起Activity

命令

adb shell

# 不指定Activity名称启动,即启动主Activity
monkey -p <packagename> -c android.intent.category.LAUNCHER 1

# 指定启动Activity名 (需要root权限)
am start -n <packagename>/<activity类名>

结果

③ 强行停止应用

命令

adb shell am force-stop <packagename>

结果

④ 模拟按键/输入/滑动/点击

命令 (完整Keycode列表可见官网:KeyEvent):

adb shell

# 模拟按键 (Home-3,返回-4,电源-26,亮屏-224、熄屏-223,切换应用-187,小键盘删除-67),如点击Home键:
input keyevent 3

# 在焦点处于某文本框时,使用input命令输入文本
input text Hello

# 滑动,从起始坐标点滑动到结束坐标点,如上滑(300,1000) → (300,500):
input swipe 300 1000 300 500

# 点击坐标点
input tap 500 500

Tipsadb默认不支持Unicode编码所以无法input中文内容,即使你默认使用了支持中文的输入法也不行,解决方法如下:

  • 下载安装 ADBKeyBoard
  • ② 安装完后依次打开:手机设置 → 语言和输入法 → 键盘 → 虚拟键盘 → 管理键盘 → 启用ADB keyboard;
  • 默认输入法 设置为ADB keyboard (比如原生系统点击右下角小键盘设置)

  • ④ 接着使用这个命令即可输入中文 (其他语言也可以):adb shell am broadcast -a ADB_INPUT_TEXT --es msg '中文输入'

⑤ 截图

命令

adb shell

# 截图
screencap -p /sdcard/sc.png

# 退出adb shell
exit

# 导出到电脑
adb pull /sdcard/sc.png

另外,还有一种一行命令截图并保存到电脑的方法:

# Mac
adb shell screencap -p | gsed "s/\r$//" > sc.png

# Linux和Windows
adb shell screencap -p | sed "s/\r$//" > sc.png

# Tips:上面的截图方法,Windows能获取图片,但是打不开,可以用下述方法获取:
adb exec-out screencap -p > sc.png

⑥ 查看分辨率

命令

adb shell wm size

结果


0x5、Android App中调用adb命令

不是很推荐 这种方式弄自动化,第一个是 权限问题,如果你的自动化设备是有 Root权限的真机或模拟器,在考虑这种吧。

另外一个问题是 保活问题,一般我们的自动化场景,都是 定时去做一些事情,比如我想每天早上8点半自动打卡,那我需要写一个后台服务,然后让它 一直挂着, 到点触发自动打卡的操作。这个 保活 在PC上很容易实现,但在手机里却很难保证...

写个简单的调用示例吧,有需要的改改就能用,先是布局:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.appcompat.widget.AppCompatEditText
        android:id="@+id/et_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="请输入adb命令" />

    <androidx.appcompat.widget.AppCompatButton
        android:id="@+id/bt_run"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="执行" />

    <androidx.appcompat.widget.AppCompatTextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="输出结果:" />

    <androidx.appcompat.widget.AppCompatTextView
        android:id="@+id/tv_output"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:text="" />

</LinearLayout>

Android中的调用代码:

class TestCmdActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_test_cmd)
        findViewById<TextView>(R.id.et_input).text =
            "monkey -p com.tencent.mobileqq -c android.intent.category.LAUNCHER 1"
        findViewById<Button>(R.id.bt_run).setOnClickListener {
            val result = execCmd(findViewById<EditText>(R.id.et_input).text.toString())
            if (result != null) findViewById<TextView>(R.id.tv_output).text = result
        }
    }

    /**
     * 执行普通命令 (不需要adb shell)
     * */
    private fun execCmd(cmd: String?): String? {
        return if (cmd.isNullOrBlank()) {
            shortToast("命令不能为空")
            null
        } else {
            try {
                val sb = StringBuffer()
                val process = Runtime.getRuntime().exec(cmd)
                val inputStream = process.inputStream
                val bufferedReader = BufferedReader(InputStreamReader(inputStream))
                val buff = CharArray(1024)
                var ch: Int
                while (true) {
                    ch = bufferedReader.read(buff)
                    if (ch == -1) break
                    sb.append(buff, 0, ch)
                }
                process.waitFor()
                bufferedReader.close()
                return sb.toString()
            } catch (e: IOException) {
                e.toString()
            }
        }
    }

    /**
     * 执行需要Root权限的命令
     * */
    private fun execCmdRoot(cmd: String?): String? {
        return if (cmd.isNullOrBlank()) {
            shortToast("命令不能为空")
            null
        } else {
            val successMsg = StringBuffer()
            val errorMsg = StringBuffer()
            var process: Process? = null
            var successResult: BufferedReader? = null
            var errorResult: BufferedReader? = null
            var os: DataOutputStream? = null
            try {
                process = Runtime.getRuntime().exec("su")
                os = DataOutputStream(process.outputStream)
                os.write(cmd.toByteArray())
                os.writeBytes("\n")
                os.flush()
                val result = process.waitFor()
                successResult = BufferedReader(InputStreamReader(process.inputStream))
                errorResult = BufferedReader(InputStreamReader(process.errorStream))
                var s: String?
                while (true) {
                    s = successResult.readLine()
                    if (s == null) break
                    successMsg.append(s)
                }
                while (true) {
                    s = errorResult.readLine()
                    if (s == null) break
                    successMsg.append(s)
                }
                return successMsg.toString() + "\n\n" + errorMsg.toString()
            } catch (e: IOException) {
                e.toString()
            } finally {
                process?.destroy()
                os?.close()
                successResult?.close()
                errorResult?.close()
            }
        }
    }
}

运行结果如下

尽管控制台看到有输出,但实际上并没有唤起QQ,说到底还是权限的问题。本想试下su提权的,结果我手机的Magsik出问题了,获取su权限直接卡死...坑还是挺多的,所以不太建议新手拿这个玩自动化哈~


0x6、Python调用adb命令 (推荐)

如题,就是编写Python脚本来调用adb命令,个人比较推荐这种方式,可玩性非常强,可以:

借助PC做定时任务 (保活)、利用PC强大性能进行图片处理、OCR识别、信息上报、多机群控等;

不太懂Python?没关系,杰哥帮你把自动化adb命令都封装一波,你按照你的逻辑直接调方法就行,跟玩积木一样~

所谓的封装,核心就是通过 subprocess 模块调一下命令行而已,非常简单,直接给出完整代码:

import subprocess
import re
from enum import Enum
import time
import os

pkg_act_pattern = re.compile(".* (.*?)/(.*?) ", re.S)  # 获取包名和Activity名的正则
chinese_pattern = re.compile("[\u4e00-\u9fa5]", re.S)  # 筛选中文的正则
size_pattern = re.compile(r"(\d+)x(\d+)", re.S)  # 获取屏幕尺寸的正则
t = time.time()


class KeyEvent(Enum):
    """
    按键事件的枚举
    """
    HOME = 3
    BACK = 4
    POWER = 26
    SCREEN_ON = 224
    SCREEN_OFF = 223
    SWITCH_APP = 187
    DELETE = 67


def start_cmd(cmd):
    """
    执行命令
    :param cmd: 命令字符串
    :return: 执行后的输出结果列表
    """
    print(cmd)
    proc = subprocess.Popen(cmd, shell=False, stdout=subprocess.PIPE)
    return proc.stdout.readlines()


def start_app(package_name):
    """
    启动APP
    :param package_name: 应用包名
    :return: 执行结果字符串
    """
    return analysis_result(start_cmd(f'adb shell monkey -p %s -c android.intent.category.LAUNCHER 1' % package_name))


def kill_app(package_name):
    """
    杀掉APP
    :param package_name: 应用包名
    :return:
    """
    return analysis_result(start_cmd(f'adb shell am force-stop %s' % package_name))


def current_pkg_activity():
    """
    获取当前页面的包名和Activity类名
    :return:
    """
    result = analysis_result(start_cmd(f'adb shell dumpsys activity activities | grep mResumedActivity'))
    print(result)
    if result is not None and len(result) > 0:
        match_result = re.search(pkg_act_pattern, result)
        if match_result:
            return match_result.group(1), match_result.group(2)
    return None


def key_event(event):
    """
    模拟按键
    :param event: 按键类型
    :return:
    """
    return analysis_result(start_cmd(f'adb shell input keyevent %d' % event.value))


def input_text(text):
    """
    当焦点处于某文本框时,模拟输入文本
    :param text: 输入文本内容
    :return:
    """
    match_result = re.findall(chinese_pattern, text)
    # 判断是否包含中文
    if len(match_result) > 0:
        return analysis_result(start_cmd(f'adb shell am broadcast -a ADB_INPUT_TEXT --es msg %s' % text))
    # 不包含中文调用原命令
    return analysis_result(start_cmd(f'adb shell input text %s' % text))


def swipe(start_x, start_y, end_x, end_y):
    """
    滑动,从起始坐标点滑动到终点坐标
    :param start_x: 起始坐标点x坐标
    :param start_y: 起始坐标点y坐标
    :param end_x: 终点坐标点x坐标
    :param end_y: 终点坐标点y坐标
    :return:
    """
    return analysis_result(start_cmd(f'adb shell input swipe %d %d %d %d' % (start_x, start_y, end_x, end_y)))


def click(x, y):
    """
    点击坐标点
    :param x:
    :param y:
    :return:
    """
    return analysis_result(start_cmd(f'adb shell input tap %d %d' % (x, y)))


def analysis_result(lines):
    """
    将执行结果列表转换外字符串输出
    :param lines: 执行结果列表
    :return:
    """
    result = ''
    for line in lines:
        result += line.decode(encoding='utf8')
    return result


def screenshot(save_dir=None):
    """
    获取手机截图,先截图后拉取(一步达成的方法好像有权限问题)
    :return: 截图文件的完整路径
    """
    sc_name = "%d.png" % (int(round(t * 1000)))
    start_cmd('adb shell screencap /sdcard/%s' % sc_name)
    sc_path = os.path.join(os.getcwd() if save_dir is None else save_dir, sc_name)
    start_cmd('adb pull /sdcard/%s %s' % (sc_name, sc_path))
    return sc_path


def screen_size():
    """
    获取屏幕分辨率
    :return: 屏幕的宽和高
    """
    size_result = re.search(size_pattern, analysis_result(start_cmd(f'adb shell wm size')))
    if size_result:
        return size_result.group(1), size_result.group(2)


if __name__ == '__main__':
    # 可以在这里写测试代码
    print(screen_size())

读者可以直接copy代码,在写测试代码那里,调下方法试试看~


0x7、实战:某办公软件打卡自动化

上面把常用的自动化操作都封装了,接着写个超简单的案例来练练手~

早上上班最怕啥?肯定是 迟到 啊,那最绝望的场景是什么?对于笔者来说莫过于:

9:00,明明已经到打卡范围了,但却因为没网一直Loading打不上,我也不知道是地铁的问题还是我手机的问题,只能反复打开关闭:飞行模式、定位、办公软件,直至时间变成了9:01,然后弹出 迟到是否打卡的提示,那一瞬间 千言万语化作一句话...

每逢此刻都会想,如何改变这种情况?第一反应想到的是 改定位,TM直接在家里就打上卡,这不美滋滋。

实现思路

  • ① 编写Xposed插件,Hook获取定位相关的API,直接返回公司附近的经纬度;
  • ② 换个支持 位置穿越 的手机,比如联想就支持改变定位地点;

但这两种方案都 很有可能被检测出来,估计是 APP运行环境相关的检测 (手机Root了,App被Hook了等) + 风控,之前老东家用这个被人事警告过 (人事那边能看到XX异常打卡,使用定位软件啥的)。

综上,这种实现方式不简单、不稳、还有高风险,妥妥滴抛弃,有没有容易、稳定、风险较低的方案呢?

当然有:开启极速打卡 + 编写自动化脚本,开启方法很简单,打卡 → 设置 → 极速打卡

如图,开启极速打卡后,你在打卡范围内打开办公软件,就能自动打卡。啧啧,那我直接:

手机一直接着电脑定时到点adb命令打开办公软件

是的,就是这么简单,接着一步步来实现,难点的话就一个,定时任务,如果是Linux系统,直接用 CrontabCelery 就好,可惜笔者的电脑是Windows,两种实现方法:

① 手动配置定时任务

就是手动添加定时任务,到点执行脚本,先把打开办公软件的脚本写出来:

import adb_util

if __name__ == '__main__':
    # 打开办公软件,然后执行这句代码,拿到应用的包名
    # print(adb_util.current_pkg_activity()[0])
    package_name = "办公应用的包名贴到这"
    adb_util.start_app(package_name)

开始菜单搜索:任务计划程序 → 打开后点击 → 任务计划程序库创建基本任务

填下任务名称和描述,点下一步:

触发器选择每天,点下一步:

编辑触发时间,点下一步:

选择启动程序,点下一步:

选择解释器所在路径,填写打卡脚本路径,点下一步:

然后可以看到任务的摘要,点击完成:

接着就可以在任务列表看到我们新建的任务了:

可以右键运行试试运行效果~

接着只需把手机插上,静待明天到点自动打卡~ (记得关掉锁屏、屏幕保持常亮、亮度调最低)


② 使用schedule库

schedule是一个轻量级的任务调度库,可以完成每分钟、每小时、每天、周几、特定日期的定时任务。

直接命令行键入 pip install schedule 安装模块,然后写出定时代码:

import adb_util
import schedule
import time

package_name = "办公软件包名"


def clock_in():
    adb_util.start_app(package_name)


if __name__ == '__main__':
    schedule.every().day.at("08:30").do(clock_in)
    while True:
        schedule.run_pending()
        time.sleep(1)

竟简单如斯!!!另外,以 python.exe xxx.py 方式启动脚本会弹出一个黑色的命令行控制行窗口,关掉的话会导致程序停止运行。如果觉得碍眼,其实可以使用 pythonw.exe xxx.py,以标准WIN32 GUI方式启动,无窗口的Python可执行程序,代码在后台执行。

关闭的话,直接打开 任务管理器,定位到 Python,然后终结进程即可~


0x8、小结

不知不觉就到文尾,读者是否还意犹未尽?本节显示科普了一波ADB相关的姿势,然后带着大家写了一个自动打卡的jio本。

虽然 简陋勉强能用,不过问题也是多多,比如这些:

  • 每天8.30准时打卡,这也太假了吧?
  • adb命令的方式启动,办公软件会不会检测到,然后判定为异常打卡?
  • 定时执行任务,但是打卡成没成功我不知道啊,万一公司断电、断网了?
  • 我下班也要看办公软件,回信息,一直放公司挂着不太现实,能不能搞个自动登录啊?
  • ...等等

行吧,那下一节就结合文字OCR和其它工具,来完善这个打卡jio本,让它变得更高大上一些~

声明:自动打卡脚本只是个练手案例,如果真的拿来使用 造成的后果使用人自担,笔者自己宁愿迟到也不会用,做人还是要诚实,经常迟到的同学建议早上早点出门,早起地铁不挤还是很香的~


参考文献