2020-07-13
一聊 Android 自动化,很多人脑子里先冒出来的是 `uiautomator2`。但如果设备不止一台、使用者不止一个、还想把接入、占用、释放、安装这些动作都管起来,只靠一个客户端库就有点撑不住了
这时候,atxserver 的位置就出来了。
它更像一层“设备管理平台”,不是直接替你点按钮的执行器,而是把下面几件事情整理成一套秩序:
如果把 uiautomator2 看成“操作设备的手”,那 atxserver 更像“安排这只手该去摸哪台机器、怎么摸、摸完怎么还回去”的管理层。
在 openatx 这套体系里,atxserver2 的定位很明确:它是移动设备管理平台。
它自己并不直接替你完成所有设备动作,而是把设备状态、占用关系、远程接入信息、provider 来源这些东西统一收进来,再通过 Web 界面和 REST API 暴露出去。
所以这套链路里,常见组件大概是这样分工的:
atxserver2:设备管理和占用调度层atxserver2-android-provider:把 Android 设备接入平台atx-agent:跑在设备侧的 HTTP 服务uiautomator2:Python 自动化客户端这四个东西一旦拆开理解,很多之前看起来有点拧巴的概念就顺了。
如果你只是本机挂一台手机,写个脚本点点页面,确实不一定需要它。
但一旦场景开始往下面这些方向走,atxserver 的价值会非常明显:
它解决的不是“单次点击怎么做”,而是“设备资源怎么管”。
把 atxserver 想成一个中枢,整个流程会比较好记:
设备接入进来以后,provider 会把设备能力和来源信息报给平台;平台把设备放进列表,记录是否在线、是否正在使用、是否处于清理状态;你的 Python 脚本再通过平台 API 拿到一台空闲机器,占用它,读取 source 信息,最后才进入真正的自动化阶段。
这条线里,平台层做的是编排,设备侧做的是执行。
官方 API 文档里,设备列表和设备详情接口暴露了几个很关键的状态位:
present:设备是否在线using:设备是否已被占用colding:设备是否处于清理、自检或暂不可占用状态properties:品牌、型号、系统版本等静态信息source:平台给你返回的接入地址集合这里最有价值的是 source。
对于 Android 设备,常见会看到这些字段:
urlatxAgentAddressremoteConnectAddresswhatsInputAddress这些字段不是摆设,而是把后续链路全接起来的钥匙:
url:provider 的入口,适合做安装、冷却等设备管理动作atxAgentAddress:设备侧 atx-agent 的访问地址,偏自动化执行链remoteConnectAddress:可用于 adb connectwhatsInputAddress:更多是远程输入场景需要,自动化脚本不一定每次都用也就是说,atxserver 的真正价值之一,就是它不只告诉你“有一台设备”,还告诉你“这台设备该怎么接、从哪儿接、接上以后还能干什么”。
如果没有 provider,平台就只是一个空壳子。
atxserver2-android-provider 做的事情,本质上是:
所以 provider 非常像“平台和真机之间的翻译层”。
这也是为什么不少人第一次看这套体系时会有点迷糊:他们以为 atxserver 一启动就该看见设备,实际上真正把设备搬进平台视野里的,是 provider。
如果你把 Web 页面先放一边,只看 API,整个模型会非常清楚。
常见的几类接口大概是:
GET /api/v1/userGET /api/v1/devicesGET /api/v1/devices/{udid}POST /api/v1/user/devicesGET /api/v1/user/devices/{udid}/activeGET /api/v1/user/devices/{udid}DELETE /api/v1/user/devices/{udid}你会发现,它的设计思路其实很稳:
这就是很典型的“资源池”味道,而不是“我看见设备就直接上手抢”。
这段代码的目标很朴素:
from __future__ import annotations
import requests
SERVER_URL = "http://127.0.0.1:4000"
TOKEN = "replace-with-your-token"
def get_headers() -> dict[str, str]:
return {"Authorization": f"Bearer {TOKEN}"}
def pick_one_device() -> dict:
resp = requests.get(
f"{SERVER_URL}/api/v1/devices",
headers=get_headers(),
params={"platform": "android", "usable": "true"},
timeout=10,
)
resp.raise_for_status()
payload = resp.json()
devices = payload.get("devices", [])
if not devices:
raise RuntimeError("no usable android device found")
return devices[0]
def acquire_device(udid: str) -> None:
resp = requests.post(
f"{SERVER_URL}/api/v1/user/devices",
headers=get_headers(),
json={"udid": udid, "idleTimeout": 600},
timeout=10,
)
resp.raise_for_status()
def get_my_device_detail(udid: str) -> dict:
resp = requests.get(
f"{SERVER_URL}/api/v1/user/devices/{udid}",
headers=get_headers(),
timeout=10,
)
resp.raise_for_status()
return resp.json()["device"]
def release_device(udid: str) -> None:
resp = requests.delete(
f"{SERVER_URL}/api/v1/user/devices/{udid}",
headers=get_headers(),
timeout=10,
)
resp.raise_for_status()
if __name__ == "__main__":
device = pick_one_device()
udid = device["udid"]
acquire_device(udid)
detail = get_my_device_detail(udid)
print("picked device:", detail["properties"])
print("source:", detail["source"])
release_device(udid)
这段代码虽然不复杂,但已经把 atxserver 的主价值跑出来了:
如果你有多台机器,这种写法会比硬编码 serial 舒服很多。
光拿到设备还不够,真正有意思的是后半段。
Android 设备详情里常见会返回 remoteConnectAddress,这个地址就是给 adb connect 用的。你可以先通过它把设备接进当前执行环境,再把 uiautomator2 接上去。
from __future__ import annotations
import subprocess
import uiautomator2 as u2
def connect_via_remote_adb(remote_addr: str):
subprocess.run(["adb", "connect", remote_addr], check=True)
return u2.connect(remote_addr)
def smoke_test(d):
print("device info:", d.info)
d.app_start("com.android.settings")
d(text="网络和互联网").wait(timeout=5)
d.press("back")
remote_addr = "10.0.0.1:20002"
d = connect_via_remote_adb(remote_addr)
smoke_test(d)
这时候你会明显感受到 atxserver 的好处:
自动化脚本不需要自己维护一份“今天哪台机器能跑、哪台机器被占了、哪台机器该连哪个地址”的表,平台已经把这些信息整理好了。
脚本只需要:
链路干净得多。
source.url 也很有价值有些动作并不适合塞进自动化客户端里,比如安装应用、触发设备冷却、做 provider 级别的设备动作。这时 source.url 就很重要。
官方 API 文档里给过一个很实用的思路:通过 provider URL 做应用安装。
如果你要用 Python 包一层,大概可以写成这样:
from __future__ import annotations
import requests
def install_apk_via_provider(provider_url: str, udid: str, apk_url: str):
resp = requests.post(
f"{provider_url}/app/install",
params={"udid": udid},
data={"url": apk_url, "launch": "true"},
timeout=120,
)
resp.raise_for_status()
return resp.json()
result = install_apk_via_provider(
provider_url="http://10.0.1.1:3500",
udid="demo-device-udid",
apk_url="https://example.com/app.apk",
)
print(result)
这个设计挺聪明的一点在于:
职责边界一下就清楚了。
真正把 atxserver 用起来以后,最容易出问题的反而不是“能不能连上”,而是资源管理的边角。
如果脚本直接拿 source 去连,而不先走占用接口,平台上的 using 状态就不准。久而久之,设备列表看起来全在线,真正拿来跑却互相撞。
官方 API 里有更新 active 时间的接口,这类设计不是多余。跑长任务时,如果平台依赖空闲超时自动释放设备,你就得认真考虑“什么时候刷新活跃状态”。
present、using、colding 不能混着看这三个状态一起看才有意义:
present=true 只是在线using=false 只是没人占colding=false 才算真的可用很多脚本只判断在线,结果老是抢到正在清理中的设备,问题就出在这里。
atxserver 适合做资源池、权限、占用、provider 路由。
uiautomator2 适合做点击、等待、截图、输入、断言。
两层如果边界不清,代码很容易长成一坨:平台脚本里夹 UI 操作,自动化脚本里又偷偷写资源调度,后面会非常难收。
哪怕你后面未必继续用完整的 openatx 组合,atxserver 这个设计思路本身还是很有价值。
它提醒了一件经常被低估的事:
当自动化规模上来以后,问题已经不再只是“脚本能不能点到控件”,而是“设备资源是不是被有序管理”。
这也是它最值得学的地方: