未来の自分へ知識貯金

ゆーきのエンジニアブログ

約5分 Pythonプログラミング

PythonでAndroidをリモート制御する【ADB】

PythonでAndroidをリモート制御する【ADB】

Android の定型操作——毎日のチェック、決まった順番のタップ、画面の監視——を PC から自動化したい。そんなときに使えるのが ADB(Android Debug Bridge) です。

ADB は Android 公式の開発ツールで、PC からコマンドを送ることでタップ・スワイプ・スクリーンショット・アプリ起動などを実行できます。相手は USB でつないだ実機でも、NOX Player や BlueStacks などのエミュレータでも OK です。

この記事では、この ADB コマンドを Python から実行できるようにクラス化したものを紹介します。最終的にこう書けるようになります。

adb.ExecTap(None, 100, 200)              # 座標(100,200)をタップ
image = adb.ExecScreenCap(None)          # スクリーンショットをOpenCV画像で取得

スクリーンショットが OpenCV 画像で取れるのがポイントで、画像認識と組み合わせると「画面を撮る → ボタンを探す → その座標をタップ」という自動化ボットが Python だけで作れます。

adb.exe のパスを確認する

ADB は単体の実行ファイル(adb.exe)です。入手方法は主に2つあります。

  • Google 公式の Platform Tools をダウンロードして展開する(実機を操作したい場合はこれ)
  • エミュレータに同梱されているものを使う(エミュレータを操作するならこちらが確実)

私は NOX Player 付属のものを使いました。以前 NOX をインストールした際、なにを思ったか E ドライブに入れていたみたいです。

私の場合は E:\nox_vm\Nox\bin\adb.exe になります。エミュレータによって場所と名前が違うので注意してください。

環境adb の場所(例)
NOX Player<インストール先>\Nox\bin\adb.exe
BlueStacks<インストール先>\HD-Adb.exe名前が HD-Adb.exe
公式 Platform Tools展開したフォルダの adb.exe

まずはコマンドを手で叩いてみる

Python に組み込む前に、コマンドプロンプトで動きを確認します。接続されている端末の一覧を出すコマンドが adb devices です。

List of devices attached 以降が実行結果です。この場合、127.0.0.1:62001 という接続先に device(正常接続)状態の端末が1台あることになります。NOX Player を起動しながら実行したのでこうなりました。実機を USB でつなぐ場合は、端末側で「USB デバッグ」を有効にしておくと、ここにシリアル番号が並びます。

タップやスワイプも同じ要領で、adb shell input touchscreen tap 100 200 のようなコマンドで実行できます。よく使うコマンドは次のとおりです。

やりたいことコマンド
端末一覧adb devices
タップadb shell input touchscreen tap <x> <y>
スワイプadb shell input touchscreen swipe <x1> <y1> <x2> <y2> <ミリ秒>
キー送信(ホーム等)adb shell input keyevent <キーコード>
テキスト入力adb shell input text <文字列>
アプリ起動adb shell am start -n <パッケージ/アクティビティ>
スクリーンショットadb exec-out screencap -p

つまり「Python から adb.exe を実行して結果を受け取る」仕組みを1つ作れば、Android を丸ごと Python で操れるわけです。以降はこれをクラスにまとめたものです。

ソースコード(クラス)

from enum import IntEnum
import subprocess
import os
import cv2
import time
import numpy as np

class AdbCtrl(object):

    class InitParam(object):
        """ 初期化パラメータ """
        def __init__(self) -> None:
            self.AdbRootDir: str = r""       # adb.exeがあるディレクトリパス
            self.IsUseHdAdb: bool = False    # BlueStacks(HD-Adb.exe)を使う場合はTrue

    class Device(object):
        """ 接続先端末情報モデル """
        def __init__(self, deviceId: str = "", deviceName: str = "") -> None:
            self.DeviceId: str = deviceId
            self.DeviceName: str = deviceName

    class eKeyCode(IntEnum):
        Home = 3     # ホームボタン
        Back = 4     # 戻るボタン
        Task = 187   # タスク一覧ボタン

    def __init__(self) -> None:
        self.initParam = AdbCtrl.InitParam()
        self.isInitialized = False

    def Initialize(self, param: InitParam) -> None:
        if (os.path.isdir(param.AdbRootDir)) == False:
            raise ValueError(f"ADBのフォルダパス({param.AdbRootDir})が不正です")
        self.initParam = param
        self.isInitialized = True

    def Dispose(self) -> None:
        self.isInitialized = False

    def IsInitialized(self) -> bool:
        return self.isInitialized

    def Sleep(self, msec: int) -> None:
        time.sleep(msec / 1000.0)

    # ---- ここからコマンド実行関数(実際に使うところ) ----

    def ExecDevices(self) -> list[Device]:
        """ 接続できる端末の一覧を返す """
        recvs = self.Query("adb devices")
        devices: list[AdbCtrl.Device] = []
        for recv in recvs:
            arr = recv.split('\t')
            if (len(arr) != 2):
                continue
            devices.append(AdbCtrl.Device(arr[0], arr[1]))
        return devices

    def ExecTap(self, device: Device, x: int, y: int) -> None:
        """ 指定座標をタップする(deviceがNoneなら1台目に接続) """
        send = f"adb {self.GetOptionDeviceStr(device)}shell input touchscreen tap {int(x)} {int(y)}"
        self.NonQuery(send)

    def ExecSwipe(self, device: Device, x1: int, y1: int, x2: int, y2: int, timeMs: int) -> None:
        """ (x1,y1)から(x2,y2)までtimeMsミリ秒かけてスワイプする """
        send = f"adb {self.GetOptionDeviceStr(device)}shell input touchscreen swipe {x1} {y1} {x2} {y2} {timeMs}"
        self.NonQuery(send)

    def ExecScreenCap(self, device: Device) -> any:
        """ スクリーンショットをOpenCV画像(BGR)で返す """
        send = f"adb {self.GetOptionDeviceStr(device)}exec-out screencap -p"
        png_bytes = self.QueryBin(send)
        image = cv2.imdecode(np.frombuffer(png_bytes, np.uint8), cv2.IMREAD_COLOR)
        return image

    def ExecKeyEvent(self, device: Device, key: eKeyCode) -> None:
        """ ホーム・戻るなどのキーイベントを送る """
        send = f"adb {self.GetOptionDeviceStr(device)}shell input keyevent {key.value}"
        self.NonQuery(send)

    def ExecInputText(self, device: Device, text: str) -> None:
        """ テキストを入力する """
        send = f"adb {self.GetOptionDeviceStr(device)}shell input text {text}"
        self.NonQuery(send)

    def ExecStartN(self, device: Device, arg: str) -> None:
        """ 指定したアプリ(パッケージ/アクティビティ)を起動する """
        send = f"adb {self.GetOptionDeviceStr(device)}shell am start -n {arg}"
        self.NonQuery(send)

    # ---- ここから内部処理 ----

    def GetOptionDeviceStr(self, device: Device) -> str:
        """ 端末指定オプション(-s)。Noneなら省略=1台目に接続 """
        if (device is None):
            return ""
        return f"-s {device.DeviceId} "

    def NonQuery(self, send: str) -> None:
        self.QueryBin(send)

    def Query(self, send: str) -> list[str]:
        recv = self.QueryBin(send).decode("utf-8")
        return recv.split("\r\n")

    def QueryBin(self, send: str) -> bytes:
        # BlueStacksの場合は実行ファイル名を差し替える
        if (self.initParam.IsUseHdAdb == True):
            send = send.replace("adb", "HD-Adb", 1)
        # パスに空白("Program Files"等)があっても動くように、exe部分を引用符で囲む
        cmd_parts = send.split(' ', 1)
        exe_path = f'"{self.initParam.AdbRootDir}\\{cmd_parts[0]}"'
        args = cmd_parts[1] if len(cmd_parts) > 1 else ""
        final_cmd = f"{exe_path} {args}"
        pipe = subprocess.Popen(final_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, shell=True)
        recv = pipe.stdout.read()
        pipe.communicate()
        return recv

コードのポイント

  • 全コマンドが最終的に QueryBin に集約されます。「adb.exe のフルパスを引用符で囲んで subprocess で実行し、標準出力を bytes で受け取る」だけの関数です。パスを引用符で囲んでいるのは、C:\Program Files\... のような空白入りパスだとコマンドが分断されて動かないためです(実際にハマりました)
  • スクリーンショットは exec-out を使うのが重要ポイントです。adb shell screencap だと出力がテキストモード経由になり、改行コード変換でPNG のバイナリが壊れますexec-out はバイナリをそのまま返すので、cv2.imdecode に直行できます。ファイル保存を経由しないので速いのもうれしいところ
  • IsUseHdAdb: BlueStacks の adb は実行ファイル名が HD-Adb.exe なので、コマンド文字列の先頭だけ差し替えています
  • device 引数は None でも動きます-s <デバイスID> オプションを省略すると1台目に接続されます。ただし端末を2台以上つないでいるときは指定必須です(省略するとエラーになります)

使い方

クラスの実体生成と初期化。

self.adbCtrl = AdbCtrl()
initParam = AdbCtrl.InitParam()
initParam.AdbRootDir = self.appConfig.AdbRootDir  # adb.exeのあるフォルダ
self.adbCtrl.Initialize(initParam)

タップするとき(x=100, y=200 の座標)。

self.adbCtrl.ExecTap(None, 100, 200)

スワイプするとき(x=100 の位置で y=100 から y=200 まで1秒かける)。

self.adbCtrl.ExecSwipe(device, 100, 100, 100, 200, 1000)

スクリーンショットを取得するとき。

image = self.adbCtrl.ExecScreenCap(device)

解放するとき。

self.adbCtrl.Dispose()

ハマりポイント

  • adb devices に端末が出てこない → 実機なら「開発者向けオプション → USB デバッグ」が ON か、接続時の許可ダイアログを承認したかを確認。エミュレータなら起動済みかを確認
  • スクリーンショットの画像が壊れるshell screencap を使っていないか確認。前述のとおり exec-out が正解です
  • input text に日本語やスペースが入力できない → ADB の input text は ASCII のみで、スペースは %s に置き換える必要があります。日本語入力が必要な場合は別のアプローチ(クリップボード連携アプリなど)が要ります
  • 座標が端末によって違う → タップ座標は解像度依存です。解像度の違う端末でも動かしたいなら、固定座標ではなく後述の画像認識で座標を求める方式にしておくと吉です

応用:OpenCV と組み合わせて「見て押す」ボットに

ExecScreenCap が OpenCV 画像を返すので、テンプレートマッチング(cv2.matchTemplate)で「ボタンの画像」を画面から探し、見つかった座標を ExecTap に渡す——という画像認識ベースの自動化が素直につながります。

image = adb.ExecScreenCap(None)                      # 1. 画面を撮る
res = cv2.matchTemplate(image, buttonImg, cv2.TM_CCOEFF_NORMED)
_, maxVal, _, maxLoc = cv2.minMaxLoc(res)            # 2. ボタンを探す
if maxVal > 0.9:
    adb.ExecTap(None, maxLoc[0] + w // 2, maxLoc[1] + h // 2)  # 3. 中心をタップ

固定座標のタップと違って画面レイアウトの変化に強く、「このボタンが出るまで待つ」といった制御もできるようになります。定型作業の自動化にどうぞ。

この記事をシェアする

ゆーき
ゆーき

プログラマー経験10年以上。好きなプログラム言語はC#。使える言語はC++, Python他。好きな開発環境はVisual Studio。

プロフィールを見る →