Auto.js 简介与避坑指南
Auto.js 是在 Android 平台上运行 JavaScript 脚本的工具,在自动化测试等方面非常有优势。本文探讨该项目的发展现状,为开发者应选择何种开源实现提供参考;探讨如何配置代码补全,提高 Auto.js 脚本编写的效率;本文也讨论 Auto.js 的局限性,结合笔者开发过程中遇到的问题,分析其原因,给出可行的解决方案。
Auto.js 的发展现状
Auto.js 由 hyb1996 于 2017/01/27 初次发布, 于 2020/03/13 因故停止维护, 最终版本名称为4.1.1 Alpha2, 构建版本号为461。
原作者停止维护后,Auto.js 分为付费版和免费开源版两种路线继续发展:
- 付费版:原作者删除了开源的所有 Auto.js 代码,宣布继续开发闭源的收费版本,但目前所有版本都已经终止开发,不再维护。
- 免费开源版:开源社区出现了多个 fork 版本,形成各自维护的局面,例如 TonyJiangWJ、kkevsekk1、 SuperMonster003、wilinz、aiselp 等作者均参与了其 fork 版本的维护。
基于种种考量,本文均使用 aiselp 作者开发的 AutoX v7.2.1。
配置代码补全,改善编程体验
Auto.js 的工具链不完善。很多开发者编写 Auto.js 时,只是依靠 VSCode 上的一个插件实现代码补全、项目管理、连接 ADB 设备等功能。然而,这个插件的自动补全功能有一些 bug 严重影响使用,ADB 连接也时常不稳定,出现断线问题。
可以用更好的方式实现代码补全。AutoX 的 GitHub 仓库中以 npm 包的形式提供了开发模板,每开启一个新项目时,用npm install安装依赖,npm 会自动下载autox-v6-api包,包内含有相关的.d.ts类型文件,以提供自动补全。
Auto.js 的代码补全在 AI 编程时代更加重要,因为 Auto.js 是小众平台,并没有大量的语料去训练 AI,中国大陆以外的地区甚至搜不到相关讨论。这导致 AI 在一些细节方面表现得不尽如人意。有了代码补全,人工改错的成本也会小一些。
至于“推送项目至手机”等功能,笔者目前自行编写批处理脚本实现,暂未找到更好的方法。
Auto.js 的局限性与避坑指南
Auto.js 使用 JS 作为脚本的语言,方便了无数开发者编写简单的脚本。但 Auto.js v6 选用 Rhino 作为在 Android 系统上执行 JS 的中间层,这引入了一些问题,例如 Rhino 并不完全支持 ECMA 规范,以及多线程支持的问题。
有限的const支持
以下代码在 Auto.js 的实际运行结果为,控制台打印了 10 次数字0:
for(leti=0;i<10;i++){constindex=i;log(index);}把const改为let,问题解决,控制台打印了 0~9 的数字:
for(leti=0;i<10;i++){letindex=i;log(index);}在 JS 规范中,每次循环都会创建一个新的块级作用域,const定义的常量只是在块级作用域中不能重新赋值,而不会影响下一次循环。但是 Auto.js 的运行结果显然不符合规范。
问题的来源:Auto.js 使用 Rhino 运行 JS 脚本,Rhino 的 JS 实现并不完全符合规范:Rhino 中
const具有函数作用域,而不是标准规定的块级作用域(block-scope)。2017 年就有开发者在 Rhino 的 GitHub 仓库中提交了关于这个问题的 issue,但 Rhino 至今没有修复完成。问题的普遍性:如果一个 JS 变量在作用域内不会被重新赋值,很多现代 IDE 和编辑器默认推荐使用
const来定义这样的变量(不论在语义上,它是否是“常量”)。例如,ESLint 默认启用prefer-const规则,VSCode 中 for 循环的自动补全也默认使用 const,如果开发者使用了这些工具,就很容易中招。问题的隐蔽性:该问题不会产生报错。假设开发者想用 for 循环遍历一个
ListView中的所有控件,出现问题时,实际却是每次循环都引用第一个控件,但没有任何报错或异常信息可以参考,非常难以排查。
如果需要在 Auto.js v6 环境下编写 for 循环,就需要注意避免使用const定义类似的变量。进一步地,在任何需要利用块级作用域特性的地方,都应该谨慎使用const定义变量。
多线程处理中的问题
JavaScript 采用事件循环机制运行,如果使用单一线程运行脚本,当脚本执行耗时操作时,整个线程将阻塞而无法处理其他事件;而 Auto.js v6 采用的 Rhino 引擎也不支持现代 JS 的异步函数。所以,需要使用 Auto.js 提供的“多线程”能力(实际上是一种模拟实现,JS 不支持多线程),将耗时操作放在单独的线程执行。
Auto.js 的文档写道:
通过
threads.start()启动的所有线程会在脚本被强制停止时自动停止。
然而,对于有些多线程脚本,在 Auto.js 应用或打包的应用中点击“停止脚本运行”后,仍然会残留一些线程持续执行。下面是一个能够复现该问题的最小示例代码,运行这段代码一段时间后尝试停止脚本,程序仍然无限循环打印着“正在执行带 try 的线程轮询!”:
functionloopWithWhileTrue():void{threads.start(function(){while(true){try{log("正在执行带 try 的线程轮询!");sleep(3000);}catch(e){log(`捕获到异常:${e}`);}}});}functionmain():void{loopWithWhileTrue();}main();去掉 try…catch 块,把脚本改写如下,该问题不再出现:
functionloopWithWhileTrue():void{threads.start(function(){while(true){log("正在执行线程轮询!");sleep(3000);}});}functionmain():void{loopWithWhileTrue();}main();查看 Auto.js 开源代码库,笔者了解到,在脚本强制停止时,每个线程会利用多种方式确保其已被停止,其中一种方式就是在线程内部抛出中止异常(InterruptedException),这样的异常经过序列化得到JavaException: com.stardust.autojs.runtime.exception.ScriptInterruptedException: null。上述出现问题的代码中使用了 try…catch 结构,中止异常被 catch 块捕获到,没有继续向上抛出,这可能使得全局的异常处理器无法感知这个 Exception,也无法控制线程停止运行。
按照这个猜想,修改脚本,对于所有未知的异常,全部继续向外层抛出:
functionloopWithWhileTrue():void{threads.start(function(){while(true){try{log("正在执行带 try 的线程轮询!");sleep(3000);}catch(e){log(`捕获到异常:${e}`);throwe;// 向外层抛出未知异常}}});}functionmain():void{loopWithWhileTrue();}main();经测试,强制终止脚本时,所有线程都可以正常停止。
这个问题带来的经验是,开发者要更加规范地使用catch机制。对于已知的异常,如 HTTP 错误等,可以在内层直接处理而不必向外抛出;对于未知异常,最好向外继续抛出直至变为 uncaught exception。这既能在早期发现问题,也避免了中断异常在线程内被catch处理,而不能发挥作用的情况。
如果因为种种原因,必须要把大部分异常都 catch 到,而不向外抛出,那么可以利用中断异常序列化后包含ScriptInterruptedException字符串的特性识别它,当识别到这种特殊的异常时向外抛出,或者主动终止线程:
functionloopWithWhileTrue():void{threads.start(function(){while(true){try{log("正在执行带 try 的线程轮询!");sleep(3000);}catch(e){log(`catch 到异常:${e}`);log(`异常类型是:${Object.prototype.toString.call(e)}`);if((easError).toString().includes("ScriptInterruptedException")){log("识别到脚本中断异常,向上 throw");throwe;// 或者不抛出,直接调用 threads.currentThread().interrupt()}}}});}用序列化的值来判断异常,这显然违背了一些公认的软件工程原则,但在 Auto.js v6 环境中,这也是一种不得已而为之的变通方法。
