码到成功

atxserver 技术博客



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

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

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

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

![架构图](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/atxserver-architecture-pkmr.svg)

## 先把 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 流程图](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/atxserver-provider-flow-pkmr.svg)

## 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 信息
- 最后把设备还回去

```python
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` 接上去。

```python
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 包一层,大概可以写成这样:

```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 链路图](https://zoytown-sg.oss-ap-southeast-1.aliyuncs.com/blogs/atxserver-python-bridge-pkmr.svg)

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

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

### 别跳过占用和释放

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

### 活跃时间不是摆设

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

### `present`、`using`、`colding` 不能混着看

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

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

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

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

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

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

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

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

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

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

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

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

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