抽象与权衡:用appium打卡百词斩和扇贝

用appium做个了小工具,用来每天打卡百词斩和扇贝。本贴总结一下其中一些设计的动机,特别是抽象和权衡。

1. 需求

手机上有 百词斩 和 扇贝单词 两个app,要求在PC上运行appium,每天完成在这两个APP中的打卡。

v2-3f1b54f6b4d3cd50e78133ae8a8de425_r

百词斩和扇贝分别需要这样几个步骤,包括 点击打卡、分享到特定的几个微信群、领取因分享带来的铜板之类的奖励。接下来,百词斩要进入小班里领"铜板",扇贝要进入听力计划中领"贝壳"。铜板和贝壳分别是对应APP中的"货币",攒着主要是习惯,其次可以在万一中断了的时候购买"补打卡"。

v2-ac7ccebf71b33c839d5ac2278def17d2_rv2-309dd1f6227d6356af91bb3481246500_r

2. 技术原型

THX和YP同学已经分别做过充分尝试,珠玉在前,在此不做赘述。参见
THX [https://www.cnblogs.com/ourshiningdays/p/16023291.html]
YP,系列博客 [Appium实现百词斩 - 萍2樱释 - 博客园](https://www.cnblogs.com/ping2yingshi/p/16206816.html)
微信图片_20220626134608
3. 抽象 与 权衡

能容易出来,PC需要向手机发出七八个或者更多的请求,点击控件,或者通过输入微信群的名字搜索,这一类的操作。如果抽象出这些动作的共性,就可以把动作视为代码,写个引擎跑这些代码。这一类抽象的思路参见《黑客与画家》,作者提到程序有三类,大意是,一种是手写业务逻辑;第二种是编译器或解释器,即引擎,跑代码,即数据;第三种是LISP。这里我实现的抽象就是第二种。

3.1 动作类别

打卡中的动作有哪些共性呢?在 Appium Inspector 中可以看到,可做的动作包括 tap, send keys,查找控件/元素可以根据 xpath, 各种 id 等。事实上,这并非*打卡*特有的动作,而是PC可以向手机发送的所有动作。考虑到打卡并无太多的特性,因此经过权衡,姑且认为 (打卡与一般的动作) 相同。

Appium Inspector 操作

这样,动作的类别包括: (1) tap, (2) sendkeys。前者用于点击按钮,后者用于向文本框发送文字。

PC与手机通信是异步非阻塞的,确保同步的最简单方便是 等待。考虑到在打卡的动作中,手机对不同步骤操作的响应时间有长有短,所以不同的步骤需要的等待时间可能不同,因此再加一种类型, (3) sleep,也算一个动作类别。

可能需要取得控件上的文字,作为下一步或进一步判断的依据。比如打卡多少天了,比如是不是达到某个条件。这个动作类别是(4)getText,在当前版本中没有实现,作为保留了。

某个特定动作是否可以执行,可能需要前置条件,例如只有当今天的单词全都背完了,才可以打卡。背完单词就是打卡的前置条件,在界面中通过检测到"打卡"而不是"开始复习吧!"来确认。这个动作类别是 (5)precondition。

在实现和测试的过程中考虑到,如果前置条件未满足,即"打卡"未出现,打卡程序应该如何呢,弹出个对话框提示我自己?不如抛异常崩掉实现起来更方便,而效果是相同的。给自己用的小工具与给别人用的程序的重要区别就在这里,友好的提示统统不必,前置条件统统可以认为满足了。要知道,所有这些都需要花时间才能写出来,如果效益不大,那就偷懒好了。

在设计的过程中,我只考虑到以上可能。其中的 precondition 可能用不到,只是隐约的感觉,还不清晰。

在代码过程中,发现自己漏掉了两种动作。 (6)back,退后一步,是个常用动作,相当于语法糖,不必获取和点击回到上一步按钮; (7)tapFirst,点击符合条件的第一个控件/元素。在搜索微信群这一类操作中,还有领铜板和贝壳界面中,手机APP的实现是类似数组中的多个元素,运行时动态生成的。所以搜索的时候会有多个匹配的,在当前问题中,只需要点击第一个。

enum ActionType
{
tap,
sendKeys,
precondition,
getText, //reserved
sleep,
back,
tapFirst, //只支持id,不支持xpath
}

3.2 元素/控件 类别

如何查找到要点击的元素/控件呢?支持了根据 (1) xpath和根据 (2) id查找。

类似 sleep 这种类型的动作,是不需要元素和控件的,此时 元素/控件 类别就是 (3) nop。

enum ElementType
{
xpath,
id,
nop; //不做任何动作
}

不支持按坐标点击或swipe,如果堕落到那种程度,就不如使用 AHK (鼠标键盘脚本) + Anlink (在PC上操作和显示手机)完成了,所占磁盘空间比 appium + appium client + android库 + jvm 要小得多。

3.3 动作

这样,动作的共性就包括 哪一种动作 (动作类型)、如何定位到控件 (元素类型)、控件地址 (无论xpath还是id,用字符串;同时复用这个属性,如果动作类型是sleep,那么代表单位为毫秒的duration)。

属性枚举型变量WhenNotFound的值有三种可能,abort,retry,ignore,分别代表当未找到控件/元素时的后续动作,是终止程序,再试一次,还是做动作序列中的下一个动作。

public class Act {

public ActionType action; //动作类型
public ElementType element; //元素类型
public String control; //控件地址

public String text;
//在procondition时作为判断条件,
//在sleep时是整数,单位毫秒,
//在其余情况下作为注释

public WhenNotFound notfound;

构造函数这样重载,在没有WhenNotFound参数的构造函数中为WhenNotFound指定了默认值abort。这也代表了小工具的典型思路,如果条件不符合要求,就终止运行,由我手动设置环境条件达到满足要求再跑。

public Act (ActionType a, ElementType e, String c, String t,    WhenNotFound f){
...
public Act (ActionType a, ElementType e, String c, String t){

3.4 动作序列

若干动作的"组合",以及对这一组合中动作的遍历,就构成了整个打卡过程。需要支持 动作之间的哪些关系呢?

首先想到的是 符合条件,一切满足要求,那么从头到尾执行一遍就行了。这是顺序执行。

其次想到,如果存在不符合条件的情况呢?例如,因为单词没背完或者听力时间不足,因此不能打卡。例如,APP今天多了一条广告,因此在特定步骤要找的控件/元素不存在,需要关了广告才行。例如,手机响应慢,sleep时间不足,需要再等3秒才能到下一动作期待的界面。例如,由于不明原因,appium java client在第一次搜索某个控件/元素的时候找不到(THX同学确认在python中没有类似现象),第二次搜索就能找到了,而且并非在第一次搜索以前sleep一会儿能够解决。这是要支持选择执行。

再次,要不要支持循环呢?向3个微信群发送打卡图片,用1组动作 (比如4个动作) 的3次循环,而不是展开成12个动作,这样好不好?这是要支持循环持行,也可以通过选择执行来实现。

每次APP发生变化时,改动以上组合中的动作,也可能需要修改 动作间的组合,引擎不应修改。

这里的其次和再次,都是对首先的更深层次的抽象。但是要考虑到,并非越抽象越好。我选择在当前版本只支持顺序执行,平坦的自前向后的执行。如果有必要,后续版本再做更深的抽象。先跑起来再说,最好是更好的敌人。

把动作放在了一个数组中,并且计划支持自前向后遍历,因此称为 动作序列。

// 初始化动作序列
Act actseq[]={
new Act (ActionType.sleep, ElementType.nop, "", "5000"),
// ---- 打卡
// new Act (ActionType.precondition, ElementType.id, "com.shanbay.listen:id/progress_bar", "41.0"),
new Act (ActionType.tap, ElementType.id, "com.shanbay.listen:id/check_status", "去打卡|已打卡"),
//  ---- 微信分享
new Act (ActionType.tap, ElementType.xpath, "//*[@text='微信好友']", "微信好友"),
new Act (ActionType.tap, ElementType.id , "com.tencent.mm:id/ebr", "搜索"),
new Act (ActionType.sendKeys, ElementType.id , "com.tencent.mm:id/cd6", "玫瑰花园"),//群
new Act (ActionType.tapFirst, ElementType.id , "com.tencent.mm:id/kpx", "玫瑰花园"),
new Act (ActionType.tap, ElementType.id , "com.tencent.mm:id/gv3", "分享"),
new Act (ActionType.tap, ElementType.id , "com.tencent.mm:id/gup", "返回扇贝听力"),
// //----------------------
new Act (ActionType.tap, ElementType.xpath, "//*[@text='微信好友']", "微信好友"),
new Act (ActionType.tap, ElementType.id , "com.tencent.mm:id/ebr", "搜索"),
new Act (ActionType.sendKeys, ElementType.id , "com.tencent.mm:id/cd6", "喵"),//群
new Act (ActionType.tapFirst, ElementType.id , "com.tencent.mm:id/kpx", "喵"),
new Act (ActionType.tap, ElementType.id , "com.tencent.mm:id/gv3", "分享"),
new Act (ActionType.tap, ElementType.id , "com.tencent.mm:id/gup", "返回扇贝听力"),
// //----------------------
new Act (ActionType.back, ElementType.nop , "", "back"),
// ---- 听力计划
new Act (ActionType.tap, ElementType.id , "com.shanbay.listen:id/mine", "我的"),
new Act (ActionType.tap, ElementType.xpath, "//*[@text='听力计划']", "听力计划"),
new Act (ActionType.sleep, ElementType.nop, "", "3000"),
new Act (ActionType.getText, ElementType.id, "com.shanbay.listen:id/plan_day", " / 天 听力计划"),
};

以上是扇贝的动作序列,百词斩的与此结构相同,差异只在动作序列中的每个元素不同。

3.5 引擎

引擎的作用是遍历动作的组合,在遍历到每个动作时执行这个动作。既然只支持顺序执行,那么就是从前向后依次访问。

引擎的触发是这样的。

//跑动作序列,loop-遍历
ci.runActionSeq (actseq);

引擎的作用是 (1)遍历,即 for (Act act : q), (2)执行序列中的当前动作,即根据动作类型 (以及参数)操作APP,即

switch (动作类型)
{   case 特定类型1:
操作APP;
break;
case 特定类型1:
操作APP;
break;
...
}

这是个标准的路子,类型于CPU执行指令。所不同者,这里因为只有序列,不支持跳转,所以没有修改下一动作下标的必要。为什么不支持跳转,因为假设所有环境条件都满足打卡要求,由我手动操作,比写代码的利益更高。这可以根据代码将 执行的次数*每次执行的时间 - 节省的手动操作时间 估算出来。

private void runActionSeq (Act q[])  throws Exception
{
AndroidDriver d = this.driver;
org.openqa.selenium.WebElement e = null;
for (Act act : q)
{
switch (act.action)
{
case tap:
e.click();
break;
case sendKeys:
e.sendKeys ( act.text);
break;
case precondition:
assert e.getText().equals (act.text);
break;
case getText:
//assert false;
System.out.println ( "==================" + e.getText() + " " + act.text + "====================");
break;
case sleep:
Thread.sleep( Integer.parseInt(act.text) );
break;
case back:
d.pressKey(new io.appium.java_client.android.nativekey.KeyEvent(io.appium.java_client.android.nativekey.AndroidKey.BACK));
break;
case  tapFirst:
java.util.List<org.openqa.selenium.WebElement> L = d.findElements(By.id(act.control));
e = L.get(0); //index从0开始
e.click();
break;
}
//默认每个动作后sleep 几秒
Thread.sleep( 2000 );
}
System.out.println ("-AFTER finally-------------------------------------------");
}
}

无论百词斩还是扇贝,以上引擎是完全相同的,二者的差别只在动作序列不同。不过在开发中,我又偷懒,没有把引擎单独做成class文件,编译和部署了两次。

这个版本的引擎,对调用者暴露了内部结构。更深层抽象的一个角度是更好的封装。可以提供一个 动作序列类,而不是 动作 (序列)数组。动作序列类提供一个方法,next或者execute_current_action,调用者在循环中不再遍历数组的元素,而是不断 next并执行当前动作,直至halt动作 (或者容器的迭代器到达 end) 。

3.6 复用

我只打卡两个APP,写第一个打卡工具的时候,连引擎带动作序列都需要写,花了较多时间。第二个打卡工具,只需要变更动作序列 (以及连接参数之类的),只几分钟就完成了。

如果有更多打卡工具要写,复用的利益可以更明显。

对于DIY这个原则,有人提到第二次遇到相同的代码就应该抽象 (成函数或者类) ,按复用的要求去写。我的耐受力更强一些,或者说更能忍,一般第三第四次遇到,才会动手抽象。并且抽象的深度要控制在这样的程度--在可预见的未来 (包括扩展和维护等) ,(节省时间的) 利益大于 (设计和写代码以及因为抽象带来的调试麻烦的)支出。

抽象/复用带来的一个直接副作用是对每个操作个体的单独处理的灵活性降低了,而只能顾及到共性。如果在共性的遍历中加上些if来强调个性,那么很快就会充满if,不如不抽象展开循环的好。

事实上,我在跑了一两个月以后,才遇到广告。后来又遇到当前单词书背完了,界面又有变化。这种小概率事件,没有必要总结,没有必要为它们变更代码做出应对。不值得。想起《额尔古纳河右岸》里,部落每到遇到什么苦难,当前做的事情就再也不做了,也不知道是为了避免悲剧再次发生,还是为了纪念。这会导致后来做什么都缚手缚脚,正月不能剪头,过年不能说不吉利的话,蒜要叫义和菜,醋要叫做忌讳,民部要改名叫户部,写代码前不能抠脚趾头,读博客前不能喝水?

微信图片_20220626134619

4. 其他讨论

还有些与工程技术有关,而与抽象/模型无关的。

4.1 环境安装,不用maven

我用appium依赖的是 java client,又不想用 maven 把挺大的库都装上,所以费了些时间。不想用maven的原因,是当时头脑不太清楚,但是基于的原则是对的。我当时误以为写完代码要部署给别人,那么运行环境与开发环境不同,不应该要求用户也装个maven。所以我花了些时间跑 appium java client,缺哪个依赖jar或class,就去找。

需要以下依赖,1.8 M。

slf4j.api-1.6.1.jar
java-client-8.0.0.jar
commons-lang3-3.12.0.jar
logback-core-1.2.11.jar
logback-classic-1.2.11.jar

以下是appium需要的,677 MB。

selenium\
appium-inspector\
appium\

还有以下是连接我的手机需要的,15.2 GB。这么大,所以,感觉节省的时间和
空间都不值得。装上maven也不会更大多少。

android-sdk-windows\

4.2 缺点

占用空间太大, 如前所述,不仅jar,class,还有android。感觉还不如用 AHK (图像搜索) + Anlink 来得轻量级(这个方案的缺点是1.对屏幕分辨率有依赖,2.语法我不如java熟悉)。就打个卡而已,16GB,太兴师动众了。

速度慢。网上有讨论,难以解决。而且速度不稳定,有时候快,有时候慢,即使环境条件毫无变化。确定的是,不用USB而用wifi连接手机,速度总是慢得不可容忍。

变更也不方便。一旦有变化,需要重新编译。在调试期间,我为此写了个bat,专门用于编译和运行。本着使用 发布环境/运行环境,而不是 开发环境 的原则,我没在IDE中开发。

编译:

set JAVA_TOOL_OPTIONS=-Duser.language=en

javac -cp %classpath%;c:\tools\selenium\*;c:\tools\appium_java_client\*;c:\tools\selenium\lib\* Act.java

javac -cp%classpath%;c:\tools\selenium\*;c:\tools\appium_java_client\*;c:\tools\selenium\lib\*CheckinBaicizhan.java

运行:

set JAVA_TOOL_OPTIONS=-Duser.language=en

java -cp %classpath%;c:\tools\selenium\*;c:\tools\appium_java_client\*;c:\tools\selenium\lib\* CheckinBaicizhan

adb -s 8KE0219819009788 shell settings put secure default_input_method com.iflytek.inputmethod/.FlyIME

运行中的最后一行,是重置手机输入法为讯飞。appium连接时有参数把手机输入法屏蔽了,为了输入文字。在此置回我常用的环境。

5. 为什么写这个贴子

首先,给学生留的作业,用appium做自动化测试的实验,比如打卡百词斩扇贝、打卡微信读书进度。我自己手痒也做了个打卡百词斩和扇贝的。本着发布是最好的保存这一原则,我也写贴子总结一下。其次,同学们经常提到的一个问题,或者在初学阶段容易陷入的一种状态,就是只有技术原型/可行性实验,然后就代码,编译并发布了。没有设计的过程,也觉得不需要模型,没啥可抽象的--老师,你看,都跑起来好使了,你还想咋样呢。我手痒写的这段代码,对打卡的动作序列稍微做了一些抽象,算是个有设计有模型的粗糙的演示。最后,在工程中,我本人也经常抽象上了瘾,停不下来,陷入到过度工程中,眼看着代码越来越抽象,越来越难维护和调试。什么时候停下抽象的脚步,什么时候妥协,是个一直需要讨论的问题。对这段小代码的讨论给出了我做妥协时的一些考虑。

同学们参与需求讨论,或者看着别的同学代码形成的过程,读起我的贴子来,可能更容易理解,也更容易挑出问题来。这也是为什么写作这一篇的一个原因。

成熟程序员读完上述贴子的感觉可能是:就这?出于对于教学的需要,简单的例子比大点的系统更容易接受,大系统里的宝藏过多,会让初学者淹灭在细节 (和各种难点) 里。而且,麻雀虽小五脏俱全,如果不能为初学者所用的技术,初学者就会感觉以后再掌握也不迟。最后,一个优秀的原则,难道不应该适用于非常广泛的领域么?按我们的所相信的生产,按我们所相信的生活,按我们所说地去做,按我们所做的去说。

-----------------------

代码不贴了,不止一个文件,也不太短,而且核心部分已经在正文中给出了。如果有感兴趣的同学,向我单独要吧,或者我发到 github 吧。

微信图片_20220626134622

Leave a Reply

Your email address will not be published. Required fields are marked *