Python UiAutomator2 框架打包 APK 实战

1. 背景

openatx/uiautomator2 是圈内久负盛名的安卓自动化框架,其通过封装安卓内置的 Uiautomator 服务,只要使用 Python 就可以快速编写自动化应用,并且框架使用 ADB 连接安卓设备对 UI 进行操作,没有 root 要求。这些都大大降低了安卓自动化开发的门槛和难度。

不过,限于语言因素,目前绝大部分应用仍然需要电脑运行 Python 脚本作为控制端,或者在 termux 中运行 python,这始终是一种比较大的局限。


这里探索一下新方案:使用 chaquopy 框架封装 uiautomator2 脚本,打包成 APK,实现仅需分发安装单个 APK 即可 ADB 调试本机并执行自动化工作,不再需要额外的控制端,实现彻底脱机运行。

下面将以 chaquo/chaquopy-console 项目为模板进行修改和说明,进行概念验证。Demo 工程可见:https://github.com/yqs112358/uiautomator2-android-demo

2. 整体思路

  • 使用 Chaquopy 执行 Python 编写的 uiautomator2 脚本,通过无线调试连接本机,这样就可以实现无需 PC 控制端,将自动化脚本打包进 APK 并分发运行。

  • uiautomator2 底层需要依赖 ADB 二进制程序进行工作,因此在 APP 中配置二进制程序的运行环境

  • 本打包方案需要无线调试处于启用状态,因此在 APP 中实现:

    • 自动启动无线调试
    • 配对设备
    • 通过 mDNS 服务发现本机无线调试的工作端口并传递给 uiautomator2

    使 uiautomator2 无需人工干预可以直接使用 ADB 连接到本机。

3. 项目配置

下载 chaquo/chaquopy-console 模板工程,使用 Android Studio 打开,初始化好项目。

可以看到项目在 MainActivity 文件中初始化了主控制台 Activity,并在 Task 子类中执行调用执行 Python 脚本,Python 脚本则存放在 app/src/main/python 目录下

大致项目结构

main.py 中直接编写一段 UiAutomator2 测试脚本,如下:

import uiautomator2

def main():
    d = uiautomator2.connect()
    print("Device has been connected. Device Info:")
    print(d.info)

    # Launch Bilibili APP
    d.app_start('tv.danmaku.bili', stop=True)
    d.wait_activity('.MainActivityV2')
    d(text="我的").wait(timeout=10)       # Wait for the splash AD to finish
    d(text="我的").click()

    # Show the fans count
    fans_count = d(resourceId="tv.danmaku.bili:id/fans_count").get_text()
    print(f"Fans count of my bilibili account: {fans_count}")

然后到 build.gradle 里配置 chaquopy 要安装的 pip 依赖包,和使用的 Python 版本号:

chaquopy {
    defaultConfig {
        version = "3.11"        // Python 版本
    }
}

android {
    ...
    defaultConfig {
        ...
        python {
            pip {
                install "uiautomator2"
            }
        }
    }
    ...
}

尝试编译运行,会发现 APP 在设备上启动一下就闪退了,Logcat 中可以看到 Python 代码报错未找到 adb 二进制程序。

4. ADB 二进制环境配置

为什么会出现这样的问题呢?翻看源码得知,UiAutomator2 底层使用的 adbutils 库需要执行 adb 二进制程序作为 server,进而与安卓设备发起连接并通信。因此我们必须要为 UiAutomator2 在安卓应用中配置好 adb 二进制运行环境。

二进制运行环境的配置参见另一篇博客《安卓 APP 执行二进制程序实践》,请务必先阅读并理解整个工作流程。

4.1. 准备 ADB 二进制及其依赖库

  1. 在手机上安装 Termux,并执行 pkg install android-tools 命令来安装 ADB 及其二进制依赖。这些程序已经使用安卓 NDK 编译过,因此可以直接取出并放到 APP 中使用

  2. 使用文件管理器打开 /data/data/com.termux/files/usr/bin,将 Termux 安装的 adb 二进制程序复制出来备用

  3. 打开 /data/data/com.termux/files/usr/lib,将 Termux 安装的 adb 的各种依赖库复制出来:

    • libabsl_***.so
    • libbrotlicommon.so
    • libbrotlidec.so
    • libbrotlienc.so
    • libc++_shared.so
    • liblz4.so
    • libprotobuf.so
    • libz.so.1.3.1
    • libzstd.so.1.5.7
  4. 不同 ADB 版本依赖的二进制库各不相同,在操作之前请先在 Termux 执行命令 ldd adb 查看 adb 二进制程序的所有依赖库,再对照着进行复制,尽可能避免遗漏。

4.2. 将依赖库放置到 jniLibs 目录

在项目 python 文件夹的同级创建一个 jniLibs 目录,里面再创建具体架构目录(比如 arm64-v8a),然后将对应架构的所有二进制文件放置进去

注意:放进去的所有二进制文件名都必须是 libxxx.so 的格式(包括 adb 自身),原因在上面的那篇文章中已经详细解释过。

如果有不符合的文件(比如 adb 程序自身,以及 libz.so.1.3.1 等库),必须先重命名成 libxxx.so 格式

将二进制文件放置到jniLibs目录下,确保文件名正确

4.3. 修改 AndroidManifest.xml

切换到 AndroidManifest.xml,为 <application> 标签加上属性 android:extractNativeLibs="true",使安装器在安装 APP 时自动将上面的这些二进制文件解压到 lib 目录;另外在开头加上 APP 访问网络的权限,以及修改无线调试设置所需要的 WRITE_SECURE_SETTINGS 权限。

修改后大致如下:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission
        android:name="android.permission.WRITE_SECURE_SETTINGS"
        tools:ignore="ProtectedPermissions" />
    ...
    <application
        ...
        android:extractNativeLibs="true">
        ...
    </application>

</manifest>

4.4. 运行时导出二进制符号链接

Utils 目录下创建 BinaryExporter.java,此类用于封装导出二进制的逻辑:

public class BinariesExporter {
    private final Application app;

    public BinariesExporter(Application app) {
        this.app = app;
    }

    // The libxxx.so for the architecture of the current platform will be linked to {linkingDir}/libxxx.so.
    //       (Note that the linkingDir must be in the application's private directory!)
    // If there is a custom name mapping in fileNameMapping, the symbolic link will be created to the corresponding file name.
    public void exportLinkTo(File linkingDir, Map<String, String> fileNameMapping) throws ErrnoException {
        if(linkingDir.exists())
            deleteDir(linkingDir);
        linkingDir.mkdirs();
        File nativeLibsFolder = new File(app.getApplicationInfo().nativeLibraryDir);
        File[] files = nativeLibsFolder.listFiles();
        if (files == null)
            return;
        for (File srcFile : files) {
            if (srcFile.isFile()) {
                String destName = srcFile.getName();
                if(fileNameMapping.containsKey(destName))
                    destName = fileNameMapping.get(destName);
                File linkFile = new File(linkingDir, destName);
                Os.symlink(srcFile.getAbsolutePath(), linkFile.getAbsolutePath());
            }
        }
    }

    private static boolean deleteDir(File f) {
        if(f == null)
            return true;
        if (f.isDirectory()) {
            String[] children = f.list();
            if (children != null) {
                for (String child : children) {
                    boolean success = deleteDir(new File(f, child));
                    if (!success) {
                        return false;
                    }
                }
            }
        }
        return f.delete();
    }
}

这样,程序在执行 Python 代码之前,只要通过如下调用就可以在其私有目录下创建 adb 及其所有依赖库的符号链接:

File adbDir = new File(getApplication().getFilesDir(), "adb");    // {PrivateDir}/adb/
File adbExecutable = new File(adbDir, "adb");                     // {PrivateDir}/adb/adb (Executable)
// Create symbolic links for ADB binaries
try {
    new BinariesExporter(getApplication()).exportLinkTo(adbDir, Map.of(
            "libz.so.1.3.1.so", "libz.so.1",
            "libzstd.so.1.5.7.so", "libzstd.so.1",
            "libadb.so", "adb"
    ));
} catch (ErrnoException e) {
    print("Fail to create symbolic link: " + e.getMessage());
}

运行完这段代码后,在 APP 私有目录下就能看到创建好的 ADB 执行环境,这里的 adb 二进制可以直接由 uiautomator2 调用运行:

创建好的二进制文件的符号链接

注意 extractADB 中的 fileNameMapping 参数:

.exportLinkTo(adbDir, Map.of(
           "libz.so.1.3.1.so", "libz.so.1",
           "libzstd.so.1.5.7.so", "libzstd.so.1",
           "libadb.so", "adb"
));

《安卓 APP 执行二进制程序实践》一文中提到过,所有 jniLibs 内的文件都必须重命名为 libxxx.so 的格式,否则不会被安卓正确安装,但是部分依赖库修改过名字以后 adb 就找不到它们了。

因此在这里创建符号链接时,传入了 “修改后名字 - 原文件名字” 的字符串对。在创建符号链接时,这些被修改过名字的二进制文件将会被还原成他们原本的名字,这样,adb 程序在实际运行的时候就可以正常找到所有原有的依赖库。

(与此同时,这里把 libadb.so 也恢复成了原来的二进制程序名 adb

5. APP 通过无线调试连接到本机

二进制环境全部准备就绪以后,还剩下最后一个问题:不通过 USB 线,如何让 uiautomator2 通过 ADB 程序连接到本机?很显然,我们使用无线调试就可以达到这个目的。

5.1. 如果:设备已 root

如果你的设备已经 ROOT,建议安装开源项目 RikkaApps/WADB,此 APP 可以强制本机 USB 无线调试开启并工作在 5555 端口。

在 WADB 服务启动后,运行 APP,adb 二进制程序会直接搜索到 5555 端口并连接到本机 USB 调试,直接同意即可:

WADB让APP可以直接找到无线调试服务

5.2. 如果:设备未 root

如果你的设备没有 root 权限,那么会稍微麻烦一些。考虑到大部分 APP 开发针对的运行环境都是未 root 设备,因此我们有必要提出对应的解决方案。

这里借鉴 Shizuku 项目的相关经验,在 APP 内自行实现无线调试服务自动开启、发现并连接的流程。

相关代码逻辑参考:RikkaApps/Shizuku

5.2.1. 创建 AdbProcessManager 类,管理 ADB 进程执行

在源代码目录下创建一个新的 adb_utils 目录,并在其中创建 AdbProcessManager.java,此类用于管理 ADB 进程的执行,并封装一些常见的 ADB 二进制逻辑。

此文件代码位于:app/src/main/java/com/chaquo/python/adb_utils/AdbProcessManager.java

5.2.2. 创建 AdbMdns 类,用于 ADB 服务发现

adb_utils 目录下继续创建 AdbMdns.java。这个类将使用 mDNS 服务发现本机正在运行的 ADB 无线调试服务,并将其工作端口返回给 APP,进而 APP 可以指导 uiautomator2 连接到 ADB 无线调试端口,全程无需人工干预。

此文件代码位于:app/src/main/java/com/chaquo/python/adb_utils/AdbMdns.java

5.2.3. 创建 AdbActivator 类,封装 ADB 服务发现和连接逻辑

adb_utils 目录下创建 AdbActivator.java,这个类用于封装 ADB 服务发现和连接、配对的逻辑,最终将接口暴露给 APP 使用。

此文件代码位于:app/src/main/java/com/chaquo/python/adb_utils/AdbActivator.java

其中核心部分的代码逻辑如下:

// Pair device
public boolean pairDevice(int pairingPort, String pairingCode) throws SecurityException
{
    if (hasWriteSecureSettingsPermission()) {
        // This permission is only granted after pairing once.
        // So if this perm is granted, we can confirm that the pairing had been finished.
        logger.d("Device had been paired. Nothing to do here.");
        return true;
    }

    logger.d("Pairing device at port " + pairingPort + " with code " + pairingCode + "...");
    if(!adbProcess.pairDevice(pairingPort, pairingCode)) {
        logger.e("Fail to pair the device!");
        throw new SecurityException("Fail to pair the device");
    }
    logger.d("Pairing succeeded.");

    // After pairing, grant the WRITE_SECURE_SETTINGS permission for current app
    grantWriteSecureSettingsPermission();
    return true;
}

// Enable wireless ADB through secure settings
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
protected void enableWirelessAdb() throws SecurityException
{
    if(!hasWriteSecureSettingsPermission()) {
        logger.e("No permission for WRITE_SECURE_SETTINGS");
        throw new SecurityException("No permission for WRITE_SECURE_SETTINGS");
    }
    logger.d("WRITE_SECURE_SETTINGS permission granted.");

    logger.d("Enabling wireless ADB...");
    final ContentResolver cr = context.getContentResolver();
    Settings.Global.putInt(cr, "adb_wifi_enabled", 1);
    Settings.Global.putInt(cr, Settings.Global.ADB_ENABLED, 1);
    Settings.Global.putLong(cr, "adb_allowed_connection_time", 0L);

    if(Settings.Global.getInt(cr, "adb_wifi_enabled", 0) != 1) {
        logger.e("Fail to enable wireless ADB");
        throw new SecurityException("Fail to enable wireless ADB");
    }
    logger.d("Wireless ADB enabled.");
}

// Discover ADB service on current device with mDNS
@RequiresApi(Build.VERSION_CODES.R)
protected CompletableFuture<Integer> discoverAdbService()
{
    final ContentResolver cr = context.getContentResolver();
    CompletableFuture<Integer> result = new CompletableFuture<>();
    ExecutorService executor = Executors.newSingleThreadExecutor();
    executor.submit(() -> {
        logger.d("Finding wireless ADB service...");
        final CountDownLatch latch = new CountDownLatch(1);
        AdbMdns adbMdns = new AdbMdns(context, AdbMdns.TLS_CONNECT, port -> {
            if (port <= 0) {
                logger.e("Fail to find wireless ADB service.");
                throw new SecurityException("Fail to find wireless ADB service");
            }
            logger.d("Wireless ADB service found at port " + port);
            result.complete(port);
            latch.countDown();
        });

        try {
            if (Settings.Global.getInt(cr, "adb_wifi_enabled", 0) == 1) {
                logger.d("Wireless ADB mDNS finding...");
                adbMdns.start();
                latch.await(3, TimeUnit.SECONDS);
                adbMdns.stop();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });

    executor.shutdown();
    return result;
}

// Returns adb port number
public CompletableFuture<Integer> enableAndDiscoverAdbPort() throws SecurityException, UnsupportedOperationException
{
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        enableWirelessAdb();
        return discoverAdbService();
    } else {
        throw new UnsupportedOperationException("Android version is too old");
    }
}

可以看到,几个方法分别实现了:

  • ADB 设备配对
  • 启用本机无线调试
  • mDNS 发现本机 ADB 工作端口并返回

这些最为关键的逻辑。

TODO

为低版本安卓适配不同的连接方案:
安卓 12:每次配对方案
安卓 11:adb tcpip 5555

6. 最后的整合

最后,我们将前面用到的这些类全部整合起来,让 APP 可以实现自动准备 ADB 执行环境、连接到无线调试并执行 uiautomator2 脚本

6.1. 修改 MainActivity 中代码

切换到 java/MainActivity.java,将 public static class Task 类修改为如下形式:

public static class Task extends PythonConsoleActivity.Task
{
    public Task(Application app) {
        super(app);
        XLog.init(LogLevel.ALL);
    }

    // Print to app console
    public void print(String text) {
        py.getModule("main").callAttr("print_to_console", text);
    }

    // This run() will be called in a background thread, no need to worry about ANR here
    @Override public void run()
    {
        // 1. Construct runtime environment in private directory for ADB binaries
        File adbDir = new File(getApplication().getFilesDir(), "adb");    // {PrivateDir}/adb/
        File adbExecutable = new File(adbDir, "adb");                     // {PrivateDir}/adb/adb (Executable)
        // Create symbolic links for ADB binaries
        try {
            new BinariesExporter(getApplication()).exportLinkTo(adbDir, Map.of(
                "libz.so.1.3.1.so", "libz.so.1",
                "libzstd.so.1.5.7.so", "libzstd.so.1",
                "libadb.so", "adb"
            ));
        } catch (ErrnoException e) {
            print("Fail to create symbolic link: " + e.getMessage());
        }

        // 2. Pair device, enable wireless ADB service and find out ADB port number
        AdbActivator adbActivator = new AdbActivator(getApplication(), adbDir.getAbsolutePath(), adbExecutable.getAbsolutePath());
        int adbPort = 0;
        try {
            adbActivator.pairDevice(40007, "924621");
            adbPort = adbActivator.enableAndDiscoverAdbPort().get();
            print("Wireless ADB service found. ADB port: " + adbPort);
        } catch (SecurityException | ExecutionException | InterruptedException | UnsupportedOperationException e) {
            print("Fail to attach to wireless ADB: " + e.getMessage());
            return;
        }

        // 3. Execute UiAutomator2 Python script
        try (PyObject mainModule = py.getModule("main")) {
            // Call: load_android_configs(app, adb_dir, ld_dir, adb_port)
            mainModule.callAttr("load_android_configs", getApplication(), adbExecutable.getAbsolutePath(), adbDir.getAbsolutePath(), adbPort);
            // Call: main()
            mainModule.callAttr("main");
        } catch (Exception e) {
            print("Error executing Python script: " + e.getMessage());
        }
    }
}

可以看到,整个 run() 方法的工作流程很清晰地分为三步:

  1. 使用 BinariesExporter 导出 ADB 程序的符号链接,创建好 ADB 二进制运行环境
  2. 使用 AdbActivator 配对设备、启动本机的无线调试,然后通过 mDNS 服务发现本机的无线调试工作端口,并记录下来
  3. 通过 Chaquopy 执行 uiautomator2 自动化脚本,将各种运行需要的参数配置传递进去,然后执行 main 函数运行其主要逻辑

6.2. 修改 Python 代码

回到最开始编写的 python/main.py 文件,将代码修改为如下形式:

import os
import sys
import warnings

import uiautomator2

warnings.filterwarnings("ignore", category=ResourceWarning)

context = None
adb_address = ""

# Load configs from Android layor
def load_android_configs(app_context, adb_path: str, ld_dir: str, adb_port: int):
    global context
    context = app_context
    global adb_address
    adb_address = f"127.0.0.1:{adb_port}"

    print("")
    os.environ["ADBUTILS_ADB_PATH"] = adb_path
    print(f"ADB Path set to: {adb_path}")
    os.environ['LD_LIBRARY_PATH'] = ld_dir
    print(f"LD_LIBRARY_PATH set to:{ld_dir}\n")

# Print something to python console
def print_to_console(text: str, end="\n"):
    print(text, end=end)


def main():
    print(f"Connecting to {adb_address}...\n")
    d = uiautomator2.connect(adb_address)
    print(f"Device has been connected. Device Info: {d.info}\n")

    # Launch Bilibili APP
    d.app_start('tv.danmaku.bili', stop=True)
    d.wait_activity('.MainActivityV2')
    d(text="我的").wait(timeout=10)       # Wait for the splash AD to finish
    d(text="我的").click()

    # Show the fans count
    fans_count = d(resourceId="tv.danmaku.bili:id/fans_count").get_text()
    print(f"Fans count of my bilibili account: {fans_count}")

注意 load_android_configs 函数,其中设置了两个重要的环境变量:

  • ADBUTILS_ADB_PATH:使 UiAutomator2 底层的 adbutils 库可以找到 adb 二进制的位置
  • LD_LIBRARY_PATH:设置动态链接库搜索路径,使 Python 层启动 adb 进程时可以从此目录找到其二进制依赖库

最后 main 函数中编写的测试代码照常不变。


在代码开始执行后,d = uiautomator2.connect() 会尝试连接到安卓设备,此时底层的 adbutils 库会根据环境变量 ADBUTILS_ADB_PATH 找到 adb 程序(的符号链接),并用 subprocess 启动它。

adb 会到 LD_LIBRARY_PATH 给定的目录下寻找其所有依赖库并加载,随后正常启动,并向本机的无线调试端口发起连接(这个端口号是之前通过 mDNS 服务发现,并传递进来的)

后面的逻辑就跟在 PC 上执行完全一致了。

7. 编译运行

编译 APP 并推送到设备上运行。

已经修改好代码的 Demo 工程位于 GitHub:https://github.com/yqs112358/uiautomator2-android-demo ,你也可以直接下载下来后编译运行。

uiautomator2 通过 ADB 成功连接本机后,接下来的测试代码会继续执行。APP 将自动拉起 Bilibili,并按代码执行自动化操作,输出结果到 APP 控制台

编写的Python代码主逻辑

UiAutomator2成功执行

可以看到,APP 启动以后,首先通过 mDNS 服务发现了本机的 ADB 无线调试端口 38015,随后引导 uiautomator2 通过 127.0.0.1:38015 连接设备的无线调试端口,最后成功执行 Python 自动化代码,启动 B 站 APP 并读取 “我的” 页面上的粉丝数量。

整个过程无需人工手动干预,完全自动化执行。而且在设备重启后仍然保持有效

8. 总结

本文对安卓 APP 独立嵌入 Chaquopy Python 引擎并执行 Python UiAutomator2 自动化进行了概念验证,证明在 APP 中执行二进制 adb 并调试自身是完全可行的。

实际场景中,建议重新从头编写 APP,并参考此文的逻辑进行 adb 运行环境的配置和 ADB 服务的自动连接,这样可以更灵活地实现所需要的业务逻辑和 APP 生命周期管理。

9. Reference

安卓 APP 执行二进制程序实践 - YQ’s Toy Box

Target SDK 29 后运行二进制文件的较好的实践

What path to put executable to run on Android 29? - Stack Overflow

RikkaApps/Shizuku: Using system APIs directly with adb/root privileges from normal apps through a Java process started with app_process.


Python UiAutomator2 框架打包 APK 实战
https://blog.openyq.top/posts/35685/
作者
yqs112358
许可协议