驭屏:把 Python 自动化测试台塞进鸿蒙 PC —— 嵌入式 CPython、协议级 HDC 与一次死锁的七天实战
驭屏:把 Python 自动化测试台塞进鸿蒙 PC —— 嵌入式 CPython、协议级 HDC 与一次死锁的七天实战
欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/
欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
适配开源地址::https://atomgit.com/OpenHarmonyPCDeveloper/ohos_AirtestIDE
一个残酷的事实:当你拥有一台鸿蒙 PC,想给手机做一轮 UI 回归测试时,你会发现整个生态里没有一个能用的自动化测试台。Windows 上那些顺手的工具——图像识别找图、脚本回放、断言报告——在鸿蒙 PC 上是空白。

这篇文章记录我把「驭屏」——一个跑在鸿蒙 PC 上的设备自动化测试 IDE——从零做到真机可用的全过程。它内嵌完整的 CPython 3.12 解释器、纯 socket 实现 HDC 守护进程协议、musllinux wheel 零重编译移植 numpy/Pillow,并在 API 26 的沙箱里撞上了一个教科书都查不到的 linker 死锁。

一、痛点:测试工程师的"人肉循环"
每个做移动端测试的人都熟悉这个循环:
- 发版了,把 App 装到测试机;
- 手动点开 App,点登录,点下单,点支付;
- 回归一遍,截图留证,写报告;
- 下个版本,再来一遍。
Windows 上的解法很成熟:截图一个按钮模板 → 脚本里 touch(Template("login.png")) → 框架在屏幕上找图 → 注入点击 → 循环断言。图像识别流派让脚本对 UI 变化不敏感,测试工程师不需要懂控件树。
但当我把工作环境切到鸿蒙 PC 后发现:这套东西的宿主端全是 Windows/Mac 独占。鸿蒙 PC 侧既没有 IDE,也没有跑测试脚本的运行时——甚至连"控制另一台设备"的编程接口都锁在 DevEco 的调试链路里。
那么,能不能让鸿蒙 PC 自己成为测试台?这就是驭屏的起点。
产品定义(一页纸说完):
- 跑在鸿蒙 PC(2in1/平板)上的 ArkTS 原生应用;
- 通过 HDC 协议连接被测设备(手机/平板/另一台 PC);
- 设备截屏实时回传,PC 端所见即所得,点击预览即注入真实触控;
- 内嵌 Python 3.12,用脚本串联"截屏-找图-点击-断言",产出测试报告。
先看成果——应用安装后的桌面图标与"模块自检"面板:

左下角控制台的 [check] 就是运行时体检:numpy 2.5.4 OK、PIL 10.3.0 OK、socket OK——这三个 OK 背后是本文第二节到第四节的所有功夫。(cv2 和 hdctool 两个 FAIL 是路线图内容,第五节会诚实展开。)
二、总体架构:一只"驭"字拆成四层
驭屏的架构可以概括为四层,每层解决一个鸿蒙特有的问题:

每一层都不是"把 Linux 的做法搬过来",而是针对鸿蒙沙箱的约束重新设计。下面按难度递增逐层展开。
三、L3:在 noexec 沙箱里养一只 CPython
3.1 问题:filesDir 是 noexec 的
标准 Linux 上嵌 Python 的套路是:解压一个 Python 发行版到数据目录,exec 它的解释器二进制。但鸿蒙应用沙箱的 filesDir(/data/storage/el2/base/...)挂载为 noexec——任何放在这里的可执行映像都无法加载。
解法分两步走:
第一步:解释器本体以共享库形式进入 HAP 的 libs/arm64-v8a/——这个目录是系统认可的执行映射区。我们直接 dlopen("libpython3.12.so"),拿到 Py_BytesMain 符号后启动解释器。核心入口只有几十行 C++:
// airtestlab_child.cpp(节选)
void *h = dlopen("libpython3.12.so.1.0", RTLD_LAZY | RTLD_GLOBAL);
auto setHome = (Py_SetPythonHome_fn)dlsym(h, "Py_SetPythonHome");
auto bytesMain = (Py_BytesMain_fn) dlsym(h, "Py_BytesMain");
setenv("PYTHONHOME", home.c_str(), 1);
setenv("PYTHONPATH", (lib + ":" + lib + "/lib-dynload:" + site).c_str(), 1);
wchar_t *w = Py_DecodeLocale(home.c_str(), nullptr);
setHome(w);
const int rc = bytesMain(argc, argv.data()); // argv = [python3, -u, script]
子进程本身由系统 API childProcessManager.startNativeChildProcess 拉起,而非裸 fork——这样它受 HarmonyOS 沙箱与生命周期管理,不会变成逃逸出治理的野进程。
第二步:stdlib 和 site-packages 都是纯 .py 数据,放在 filesDir(noexec 不影响源码加载),但C 扩展(.so)不能放那里。
3.2 236 个扩展的"平铺 + 重定向"
OHOS 的 linker namespace 禁止应用进程 dlopen 数据目录里的共享库。numpy 的 _multiarray_umath.so、PIL 的 _imaging.so 放在 site-packages/ 里就是加载不出来。
我们的构建管线把全站点的 C 扩展做了三重改造:
① 平铺重命名——扫描 site-packages 全树,把每个扩展按"模块全名"重命名后汇集到 HAP libs:
# restage_p2.py(节选)
def add_extension(fullname, src):
# numpy._core._multiarray_umath
# → numpy__core__multiarray_umath.cpython-312-aarch64-linux-ohos.so
flat = fullname.replace('.', '_') + EXT_SUFFIX
shutil.copy(src, os.path.join(LIBS, flat))
mapping[fullname] = flat
② 运行期重定向——生成一个嵌入了映射表的 sitecustomize.py,用自定义 MetaPathFinder 抢在常规导入器之前定位扩展:
# sitecustomize.py(构建期自动生成)
class _LibsRedirectFinder:
def find_spec(self, fullname, path=None, target=None):
fn = REDIRECT.get(fullname) # 精确全名匹配
if fn is not None:
origin = _LIBS + '/' + fn # HAP libs 里的平铺文件
if os.path.exists(origin):
return ModuleSpec(fullname, _LibsExtLoader(), origin=origin)
return None
sys.meta_path.insert(0, _LibsRedirectFinder())
关键设计是全名精确匹配——import numpy._core 永远不会被 numpy__core 的映射劫持,包与子包不会被平铺文件名遮蔽。
③ 补链接——用 lief 给每个扩展补写 DT_NEEDED → libpython3.12.so.1.0,保证所有扩展解析 Py_* 符号时落到同一个解释器实例上,避免出现"两个 Python"的符号分裂。
最终 HAP libs 里躺着 236 个扩展 + 59 个 wheel 自带依赖库(openblas、libffi 等,soname 原样保留),import numpy 的完整链路变成:元路径查找器 → HAP libs 的平铺 so → 全局 libpython 符号。
3.3 musllinux wheel:零重编译的生态移植
numpy 和 Pillow 我没有交叉编译。OHOS 与 Alpine Linux 同属 musl libc,这意味着 PyPI 上现成的 musllinux_1_1_aarch64 wheel 理论上可以直接重标签使用:
pip download Pillow<11 --only-binary :all: \
--platform musllinux_1_1_aarch64 --python-version 312 --abi cp312
wheel 解压进 site-packages 后,其 C 扩展走一遍上面的平铺管线(musl 后缀 .cpython-312-aarch64-linux-musl.so 与 OHOS 后缀统一识别),依赖库(pillow.libs/ 里的 libjpeg、libtiff 等 17 个)按原 soname 拷贝到 HAP libs——LD_LIBRARY_PATH 已由子进程入口指向那里。
真机实测:numpy 2.5.4 加载成功且数值计算正确,PIL 10.3.0 七个扩展全部就位。整条科学计算栈零行 C 代码重编——这是整个项目里性价比最高的一步。
3.4 一次性 Dispatch:跨进程命令协议
解释器跑起来之后,UI 怎么调度它?最直觉的设计是"常驻内核 + 文件队列":UI 写 cmd.json + 序号文件,子进程轮询消费、写结果文件。这个方案在老设备上验证过,但在这台 API 26 的真机上,它彻底失效——
- UI 写的命令文件,子进程
os.path.exists()永远为 False; - 子进程写的结果文件,UI 能
stat到大小,open却报 No such file。
翻 appspawn 日志才发现:NativeChildProcess 的沙箱挂载大面积 errno:13,主进程与子进程对 filesDir 的文件视图是隔离的。这台设备上,任何"跨进程共享文件"的设计都是空中楼阁。
绕开的思路很朴素:别共享文件,共享内核管道。每次请求 spawn 一个全新的 Python 进程——参数走 argv,结果走 stdout:
# atl_dispatch.py —— 每次请求的入口
_fn = sys.argv[1] # "exec" / "dev_screenshot" / ...
_raw = sys.argv[2] # JSON 数组
try:
_args = json.loads(_raw)
_res = _dispatch(_fn, _args)
except Exception:
_res = {"ok": False, "error": traceback.format_exc(limit=5)}
print("@RES@" + json.dumps(_res)) # 单行结果帧
UI 侧把参数做 hex 编码后拼进 argv(\0 分隔),启动子进程后非阻塞轮询 fd 读 stdout,直到读到 @RES@ 行:
// Index.ets(节选)—— 非阻塞轮询,避免同步 NAPI 冻结 UI 线程
let collected = '';
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const r: ReadResult = airtestLabBridge.readFd(this.parentFd, 65536);
if (r.status === 'data' && r.bytes > 0) {
collected += r.text;
} else if (r.status === 'eof') {
break;
}
await sleepMs(80); // 让出主线程,UI 保持渲染
}
return this.extractRes(collected); // 提取 "@RES@{...}" 单行 JSON
代价是每次请求 ~1.5s 的解释器冷启动。换来的是三个更值钱的性质:崩溃自愈(任何脚本把进程搞挂,下一个请求自动是新进程)、零状态泄漏、完全确定的执行语义。对一个测试台来说,确定性的价值高于毫秒级延迟。
四、L4:协议级直连,把 hdc 装进沙箱
4.1 沙箱里没有 hdc
有了 Python 还不够——自动化测试的"手"必须是设备操控。普通思路是调用 hdc 命令行工具,但应用沙箱禁止 exec 外部二进制,hdc 进不来。
替代思路有三层,全部走通后发现根本不需要 hdc 二进制:
第一层:协议直连。HDC 的 TCP 协议意外地简单——TCP 连接后,服务端先发 16 字节 banner("OHOS HDC" + 4 字节 channelId),客户端回写 banner + 32 字节 NUL 填充的 connect_key;之后全部报文都是 4 字节大端长度 + 载荷 的帧。用纯 socket 实现,核心一百三十行:
# atl_hdc.py(节选)
class Connection:
def connect(self, connect_key=None):
self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
data = self.read_value() # 4B 长度 + 16B banner
hs = _Handshake(data)
if not hs.banner.startswith(b"OHOS HDC"):
raise HdcError("bad banner: %r" % hs.banner[:16])
self.send(hs.reply(connect_key)) # banner + 32B connect_key
return self
def send(self, data):
self.sock.sendall(len(data).to_bytes(4, "big") + data)
def read_value(self):
length = int.from_bytes(self._read_exact(4), "big")
return self._read_exact(length)
shell <命令> 是文本帧,list targets 也是——设备列举、命令执行、参数读取全部协议级完成,没有进程启动开销。
第二层:反向转发。应用进程里没有 hdc server 可连(鸿蒙 PC 自己是被控端,跑的是 hdcd)。但应用看得见系统 hdcd 监听的端口。在 host 侧执行一次:
hdc -t <connect_key> rport tcp:8710 tcp:8710
设备侧随即出现 127.0.0.1:8710 监听,它把连接隧道回 host 的 hdc server。应用连本机 8710,等于获得了与 host hdc 完全等价的设备操控面——实测列出设备、截屏、注入触控全部走通。
第三层:设备语义。截图与点击都是 hdcd shell 里的系统命令组合:
# atl_dev.py(节选)
def screenshot_jpeg_bytes(host, port, key):
c = make_client(host, port)
c.shell(key, "snapshot_display -f /data/local/tmp/atl_screen.jpeg")
out = c.shell(key, "base64 /data/local/tmp/atl_screen.jpeg")
return base64.b64decode(re.sub(r"\s+", "", out))
def tap(host, port, key, x, y):
c = make_client(host, port)
return c.shell(key, f"uitest uiInput click {int(x)} {int(y)}")
截图走 base64 是刻意的:shell 通道对二进制流的 PTY 变形不可控,文本编码是唯一安全的选择。400KB 的 JPEG 变 533KB 文本,8710 隧道毫无压力。
4.2 效果:看-点-看实时回路
启动应用、列出设备、连上目标后的主界面:

控制台里这条日志值得逐字读一遍——它意味着沙箱内的 Python 3.12.9 已经活着,并且通过协议直连看到了设备:
[ping] {"ok": true, "py": "3.12.9 (main, Apr 16 2026, 03:33:25)
[Clang 15.0.4 (.../llvm-release/2026_0313_llvm15_release/toolchain/ll",
"platform": "ohos"}
[dev] 127.0.0.1:8710 · 1 台设备 · 3QC0124C20001270
点「截屏」按钮,设备画面经 base64 通道回传,PixelMap 解码渲染进左侧预览:

预览里显示的是被测设备(这台 PC 自己)的实时画面。点抖音的播放页试试:

控制台的 [shot] decoded 3120x2080 说明完整链路(hdcd shell → snapshot_display → base64 → 解码 → PixelMap)已经稳定。此时点击预览画面上的任意位置,坐标按信箱模型(objectFit.Contain 的留白补偿)换算回设备物理分辨率,经 uitest uiInput click 注入——被测设备真的被点了一下,随后应用自动重截屏刷新预览。"看—点—看"回路闭合。
五、踩坑实录:四个教科书查不到的坑
坑 1:DevEco 自动签名的"隐形引用"
DevEco 自动签名会生成 p12/cer/p7b 三件套并写入 build-profile.json5 的 signingConfigs——但不会在 products 里写入引用。构建出的永远是 unsigned 包,报 9568320 no signature file。修法一行:
"products": [{ "name": "default", "signingConfig": "default", ... }]
改 bundleName 后还会遇到第二层:DevEco 日志里一句 The signature already exists and does not need to be regenerated——它按"项目"缓存签名存在性,感知不到 bundleName 已变。解法是把旧签名材料移出 .ohos/config,逼它重新申请。
坑 2:跨进程文件视图隔离
第三节提到的 filesDir 隔离值得单独记录症状,因为它的表现极具迷惑性:stat 能看到文件、accessSync 返回 true、listFile 却列不出来、open 报 No such file。三个检查器三种答案,唯一的解释是挂载视图不一致。翻 appspawn 日志看到成片的 errno:13 private mount failed 才定案。
教训:在鸿蒙的 NativeChildProcess 架构里,任何"跨进程共享文件"的设计都要先验证视图一致性——或者从一开始就选 fd/管道。
坑 3:同步 NAPI 冻结 UI 线程
初版用 readUntilEof(fd, timeoutMs) 同步等待子进程输出——这是阻塞调用,UI 线程整个冻结 90 秒。期间截图永远是旧帧,控制台永远不刷新,dumpLayout 抓到的也是过期 UI 树。三个"官方"观察通道同时失效,一度让人怀疑代码没执行——其实都在跑,只是渲染停了。
修复是换成非阻塞轮询(见 3.4 的 ArkTS 代码),每次 readFd 后 await sleepMs(80) 让出主线程。此后控制台实时刷新,一切可见。
坑 4:子进程 dlopen 死锁(未解,但已定位到边界)
最顽固的一个。子进程里 dlopen 含 C++ 运行时依赖的库(libopencv_core)会无限挂起——无论 LOCAL/GLOBAL 模式、无论动态/静态链接 libc++、无论开关 NEON dispatch 与线程池。而 stdlib 扩展和 PIL(链 GNU libstdc++)全部正常,同一批库在主进程 dlopen 全部成功。
三个维度排除后边界收得很窄:是 API 26 NativeChildProcess 沙箱挂载不完整(appspawn 成片的 errno:13)与 musl linker 解析 libc++ 依赖链的组合问题。给原生桥加了一个 tryDlopen 探针做 A/B:
// airtestlab_bridge.cpp —— 主进程 dlopen 探针
napi_value TryDlopen(napi_env env, napi_callback_info info) {
// ...读取路径参数...
void *h = dlopen(path.c_str(), RTLD_NOW | RTLD_GLOBAL);
const char *err = h == nullptr ? dlerror() : nullptr;
// 返回 {ok, error} 给 ArkTS
}
主进程全部 ok=true——文件本身无恙,问题锁死在子进程环境。目前两条候选路线:改用 Alpine musl GCC 工具链产出 GNU libstdc++ 版 opencv(与已验证的 wheel 生态对齐),或把模板匹配下沉为主进程 NAPI。工程记录与复现命令全部开源在仓库,欢迎社区接力。
坑 5:hilog 的静默丢弃与"三重观察陷阱"
调试期间发现应用日志在 hilog 里永远查不到——domain 用了 0x0000(被系统静默丢弃,应用 domain 必须在 0xD0000+),改了之后 warn 级仍偶发不出。结合坑 3 的截图旧帧与 dumpLayout 缓存,三条观察通道曾同时给出假象。这段经历让我固化了一条原则:嵌入式系统的调试要以"内核管道"(文件 fd、stdout 帧)为第一证据链,渲染层的一切都可能是缓存。
六、QA:你可能想问的
Q1:为什么不用 ArkTS 把整个工具重写成 T0 纯原生?
A:经济学问题。图像识别、数值计算、模板匹配依赖 numpy/OpenCV 这套成熟生态,ArkTS 侧从零重写科学计算栈的投入产出比极差。原生部分(UI、进程治理、协议、fd 治理)我们已经全部原生实现了——T1 的"1"恰恰是刻意保留的。
Q2:为什么嵌入 CPython 而不是让用户装个 Python?
A:鸿蒙应用没有"安装解释器"的合法路径:沙箱 noexec、无包管理器、无系统级安装位。运行时必须随 HAP 分发、静态链接、离线可用——这也是对测试台的基本要求:拿去工厂/现场不能依赖网络。
Q3:numpy 和 Pillow 是怎么跑在鸿蒙上的?重新编译了吗?
A:没有。OHOS 是 musl libc,与 Alpine 同源,PyPI 官方的 musllinux_1_1_aarch64 wheel 重标签后直接可用。真正的工作在"加载层":平铺重命名 + meta_path 重定向 + DT_NEEDED 补链,让 linker namespace 的限制被结构性地绕开。
Q4:每次命令都冷启动一个 Python 进程,不慢吗?
A:约 1.5s/次。换来崩溃自愈、零状态泄漏、确定性执行。对交互式调参确实有感,但对批量跑用例(核心场景)影响可以摊平;后续如果需要 REPL 体验,会走 socketpair 常驻通道——那是 fd,不受文件视图隔离影响。
Q5:沙箱里连 hdc 都不能执行,设备控制的安全性怎么保证?
A:驭屏不申请任何受限权限(仅 INTERNET 普通权限),设备操控面完全来自用户显式配置的 HDC 链路(rport 是 host 侧一条命令)。它复用的正是开发者连接设备时本来就授权的通道——工具不提权,只重组。
Q6:为什么自己实现 HDC 协议,而不是用现成库?
A:PyPI 上的 hdctool 对协议实现得很好,但它混合了 subprocess 路径(沙箱内不可用)。它的协议层是干净可参考的——握手 banner、4 字节大端帧、命令语义——我们据此独立实现,去除所有 exec 路径,也顺带把依赖降为零。
Q7:截图为什么走 base64?直接 cat 二进制不行吗?
A:shell 通道对二进制流的转换行为(换行处理、边界帧切分)在 hdcd 各版本间不可控,二进制直读是赌博。base64 是文本安全的唯一选择,JPEG ~400KB 变 533KB,内网隧道毫无压力。
Q8:包体 200MB,值吗?
A:200MB 里 150MB 是 Python 运行时(stdlib + numpy + Pillow + airtest 源码)。对标 Windows 上同类工具动辄 1-2GB 的安装体积,这是把"测试台"装进口袋的价格。而且全部离线可用——现场测试不背网络债。
Q9:能测 iOS/Android 设备吗?
A:当前控制层基于 hdcd 生态,目标设备是鸿蒙手机/平板/PC。图像识别流派的框架抽象天然支持多后端(adb/idb 只是替换 shell 通道),在路线图上,但不是现在的重点。
Q10:cv2 的问题最终怎么解?
A:两条路。路线 A:用 Alpine musl GCC 工具链产 GNU libstdc++ 版 opencv——PIL 已证明 GNU 链在子进程 dlopen 正常,这是与已验证生态对齐的正解;路线 B:把模板匹配下沉为主进程 NAPI(主进程 dlopen 已验证正常)。无论哪条,工程与复现命令已全部开源。
七、路线图
- P3 · 图像识别闭环:解决子进程 dlopen,点亮
touch(Template("x.png")) / exists / assert_exists语义; - P4 · 工程化:
.air项目管理、模板裁剪编辑器、harmony://设备层、HTML 测试报告(ArkWeb 渲染); - P5 · 生态:多设备并行调度、用例录制回放、CI headless 模式。
八、结语
这个项目最大的收获不是"又一个 PC 移植",而是把鸿蒙 PC 应用的能力边界摸清楚了:
- 解释器可以嵌入——静态链接 + dlopen + 平铺扩展,Python 生态完整可用;
- 协议可以直连——不依赖外部二进制,一百三十行 socket 实现 HDC 客户端;
- 系统机制要实测——挂载隔离、linker 死锁、hilog 丢弃,文档里没有的坑只能真机踩出来;
- T1 不是退让——原生宿主 + 集成运行时的形态,可以让重生态应用在鸿蒙 PC 上活得体面。
如果它在你的测试工作流里省下过一次"人肉点屏幕",欢迎 Star;如果你在子进程 linker 死锁上有思路,请务必来 issue 区聊聊——那个坑值得更多人一起踩。
更多推荐



所有评论(0)