atxserver 技术博客




2020-07-13

blog_main_img

一聊 Android 自动化,很多人脑子里先冒出来的是 `uiautomator2`。但如果设备不止一台、使用者不止一个、还想把接入、占用、释放、安装这些动作都管起来,只靠一个客户端库就有点撑不住了

这时候,atxserver 的位置就出来了。

它更像一层“设备管理平台”,不是直接替你点按钮的执行器,而是把下面几件事情整理成一套秩序:

  • 哪些设备在线
  • 哪些设备正在被谁使用
  • 设备侧暴露了哪些地址给自动化层
  • provider 怎么把真机接进来
  • 自动化脚本怎么拿到一台能用的机器

如果把 uiautomator2 看成“操作设备的手”,那 atxserver 更像“安排这只手该去摸哪台机器、怎么摸、摸完怎么还回去”的管理层。

架构图

先把 atxserver 的角色摆正

在 openatx 这套体系里,atxserver2 的定位很明确:它是移动设备管理平台。

它自己并不直接替你完成所有设备动作,而是把设备状态、占用关系、远程接入信息、provider 来源这些东西统一收进来,再通过 Web 界面和 REST API 暴露出去。

所以这套链路里,常见组件大概是这样分工的:

  • atxserver2:设备管理和占用调度层
  • atxserver2-android-provider:把 Android 设备接入平台
  • atx-agent:跑在设备侧的 HTTP 服务
  • uiautomator2:Python 自动化客户端

这四个东西一旦拆开理解,很多之前看起来有点拧巴的概念就顺了。

为什么 atxserver 值得单独聊

如果你只是本机挂一台手机,写个脚本点点页面,确实不一定需要它。

但一旦场景开始往下面这些方向走,atxserver 的价值会非常明显:

  • 设备变多了,不能再靠口头喊“这台先别动”
  • 多个人同时跑脚本,需要设备占用机制
  • 远程调试时,需要知道一台设备对应哪些接入地址
  • 想把设备安装、冷却、自检这些动作交给 provider
  • 希望平台层知道“设备在线但不可用”和“完全空闲可拿来跑任务”的区别

它解决的不是“单次点击怎么做”,而是“设备资源怎么管”。

一条比较顺的心智模型

atxserver 想成一个中枢,整个流程会比较好记:

设备接入进来以后,provider 会把设备能力和来源信息报给平台;平台把设备放进列表,记录是否在线、是否正在使用、是否处于清理状态;你的 Python 脚本再通过平台 API 拿到一台空闲机器,占用它,读取 source 信息,最后才进入真正的自动化阶段。

这条线里,平台层做的是编排,设备侧做的是执行。

atxserver 里最值得理解的几个字段

官方 API 文档里,设备列表和设备详情接口暴露了几个很关键的状态位:

  • present:设备是否在线
  • using:设备是否已被占用
  • colding:设备是否处于清理、自检或暂不可占用状态
  • properties:品牌、型号、系统版本等静态信息
  • source:平台给你返回的接入地址集合

这里最有价值的是 source

对于 Android 设备,常见会看到这些字段:

  • url
  • atxAgentAddress
  • remoteConnectAddress
  • whatsInputAddress

这些字段不是摆设,而是把后续链路全接起来的钥匙:

  • url:provider 的入口,适合做安装、冷却等设备管理动作
  • atxAgentAddress:设备侧 atx-agent 的访问地址,偏自动化执行链
  • remoteConnectAddress:可用于 adb connect
  • whatsInputAddress:更多是远程输入场景需要,自动化脚本不一定每次都用

也就是说,atxserver 的真正价值之一,就是它不只告诉你“有一台设备”,还告诉你“这台设备该怎么接、从哪儿接、接上以后还能干什么”。

provider 流程图

provider 为什么是这套系统里特别关键的一层

如果没有 provider,平台就只是一个空壳子。

atxserver2-android-provider 做的事情,本质上是:

  • 发现连接到宿主机上的 Android 设备
  • 把必要资源推送到手机上
  • 把设备状态和 source 信息同步给平台
  • 提供安装应用、冷却设备等面向平台的操作入口

所以 provider 非常像“平台和真机之间的翻译层”。

这也是为什么不少人第一次看这套体系时会有点迷糊:他们以为 atxserver 一启动就该看见设备,实际上真正把设备搬进平台视野里的,是 provider。

用 API 看 atxserver,思路会更干净

如果你把 Web 页面先放一边,只看 API,整个模型会非常清楚。

常见的几类接口大概是:

  • 获取当前用户信息:GET /api/v1/user
  • 获取设备列表:GET /api/v1/devices
  • 获取单台设备:GET /api/v1/devices/{udid}
  • 占用设备:POST /api/v1/user/devices
  • 更新活跃时间:GET /api/v1/user/devices/{udid}/active
  • 获取当前用户占用的设备详情:GET /api/v1/user/devices/{udid}
  • 释放设备:DELETE /api/v1/user/devices/{udid}

你会发现,它的设计思路其实很稳:

  • 平台先判断设备能不能被拿
  • 用户拿到设备以后,才会获得更完整的 source 信息
  • 占用期间可以持续刷新活跃时间
  • 脚本跑完以后,再把设备释放回池子

这就是很典型的“资源池”味道,而不是“我看见设备就直接上手抢”。

一个实用的 Python 例子:从平台拿一台空闲设备

这段代码的目标很朴素:

  • 从平台拿一台 Android 空闲机
  • 占用它
  • 读取它的 source 信息
  • 最后把设备还回去
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 的主价值跑出来了:

  • 不是直接盲连设备
  • 先问平台有没有可用资源
  • 真正拿到设备后,再读取 source 细节
  • 用完及时释放

如果你有多台机器,这种写法会比硬编码 serial 舒服很多。

Python 再往前走一步:拿到 source 后接自动化

光拿到设备还不够,真正有意思的是后半段。

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
  • 接自动化客户端

链路干净得多。

provider 的 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)

这个设计挺聪明的一点在于:

  • 平台负责告诉你 provider 在哪
  • provider 负责把安装动作落到具体设备
  • 自动化客户端不用什么事都揽过来

职责边界一下就清楚了。

Python 链路图

一旦设备开始多人共用,几个细节就会变得很重要

真正把 atxserver 用起来以后,最容易出问题的反而不是“能不能连上”,而是资源管理的边角。

别跳过占用和释放

如果脚本直接拿 source 去连,而不先走占用接口,平台上的 using 状态就不准。久而久之,设备列表看起来全在线,真正拿来跑却互相撞。

活跃时间不是摆设

官方 API 里有更新 active 时间的接口,这类设计不是多余。跑长任务时,如果平台依赖空闲超时自动释放设备,你就得认真考虑“什么时候刷新活跃状态”。

presentusingcolding 不能混着看

这三个状态一起看才有意义:

  • present=true 只是在线
  • using=false 只是没人占
  • 还得 colding=false 才算真的可用

很多脚本只判断在线,结果老是抢到正在清理中的设备,问题就出在这里。

平台层和执行层不要互相越位

atxserver 适合做资源池、权限、占用、provider 路由。

uiautomator2 适合做点击、等待、截图、输入、断言。

两层如果边界不清,代码很容易长成一坨:平台脚本里夹 UI 操作,自动化脚本里又偷偷写资源调度,后面会非常难收。

如果你要维护这套体系,最值得保留的是什么

哪怕你后面未必继续用完整的 openatx 组合,atxserver 这个设计思路本身还是很有价值。

它提醒了一件经常被低估的事:

当自动化规模上来以后,问题已经不再只是“脚本能不能点到控件”,而是“设备资源是不是被有序管理”。

这也是它最值得学的地方:

  • 用平台层抽象设备资源
  • 用 provider 把物理设备接入平台
  • 用 source 信息把设备管理和自动化执行衔接起来
  • 用占用、活跃、释放把多人协作管住