Appium appium 中 sendkeys 方法会输入原有字符的原因及解决方案

陈恒捷 · 2015年03月22日 · 最后由 Erichthionius 回复于 2016年12月07日 · 133 次阅读
本帖已被设为精华帖!

前言

之前看到一个帖子(appium 1.3.4.1 版 sendkey 错误),里面提到了在 appium 1.3.4.1 中用 sendKeys 输入文本会变成追加文本,而 appium 1.2.4 则没有这个问题。同时如果 sendKeys 前有先 clear 也不会出现这个问题。周末有时间在源码环境下探究了一下具体原因,结果不仅发现了具体原因,还发现了一个小 bug。

调试环境

appium 版本:1.3.6(REV faf0873919a70c930b32df48e7653e8fe830a142)
所用 IDE: WebStorm(node.js 部分),IDEA(Android 上使用的 bootstrap 部分)
源码修改: 为了能对 bootstrap 进行远程调试,我在appium/libs/devices/android/uiautomator.js的启动参数中添加了-e debug true这个参数:

被测 app: 自行制作的 app,里面只有一个界面,界面中有一个 editText 控件,控件的默认值为 “中文”(非 hint text)。控件具有 content-description,因此脚本中使用 id 来查找控件。

注意:添加了这个参数后 bootstrap 启动时会一直等待直到有 debugger 联系到它。所以非 debug 用途请勿做此修改。
至于如何设置远程调试 bootstrap,调试方法和调试 Android UIAutomator 一样。由于不是本文的重点,因此不再仔细介绍,有兴趣的同学请自行搜索。

SendKeys 工作过程 log 分析

SendKeys 从 appium server->bootstraps->最终输入 的完整 log 如下:

info: --> POST /wd/hub/session/be7c34f0-e12d-4368-9f80-c116e0cc3990/element/1/value {"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990","id":"1","value":["f","i","r","s","t"]}
info: [debug] Pushing command to appium work queue: ["element:setText",{"elementId":"1","text":"first","replace":false}]
info: [debug] [BOOTSTRAP] [debug] Got data from client: {"cmd":"action","action":"element:setText","params":{"elementId":"1","text":"first","replace":false}}
info: [debug] [BOOTSTRAP] [debug] Got command of type ACTION
info: [debug] [BOOTSTRAP] [debug] Got command action: setText
info: [debug] [BOOTSTRAP] [debug] Attempting to clear using UiObject.clearText().
info: [debug] [BOOTSTRAP] [debug] Sending plain text to element: 中文first
info: [debug] [BOOTSTRAP] [debug] Returning result: {"value":true,"status":0}
info: [debug] Responding to client with success: {"status":0,"value":true,"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990"}
info: <-- POST /wd/hub/session/be7c34f0-e12d-4368-9f80-c116e0cc3990/element/1/value 200 9119.841 ms - 76 {"status":0,"value":true,"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990"}

简单说一下各个 log 是什么意思:

info: --> POST /wd/hub/session/be7c34f0-e12d-4368-9f80-c116e0cc3990/element/1/value {"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990","id":"1","value":["f","i","r","s","t"]}

表示 server 接收到一个 post 类型的 http 请求,post 的 url 是/wd/hub/session/be7c34f0-e12d-4368-9f80-c116e0cc3990/element/1/value,post 请求的内容 (即 http 协议中的的 body 部分) 为{"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990","id":"1","value":["f","i","r","s","t"]}
此部分的通讯遵循 webdriver 协议,无论哪个 client 发出来都是采用这个格式

info: [debug] Pushing command to appium work queue: ["element:setText",{"elementId":"1","text":"first","replace":false}]

表示 server 把["element:setText",{"elementId":"1","text":"first","replace":false}]这个命令放到待处理命令序列中。

info: [debug] [BOOTSTRAP] [debug] Got command of type ACTION
info: [debug] [BOOTSTRAP] [debug] Got command action: setText

表示 bootstrap 收到类型为 action,名称为 setText 的命令

info: [debug] [BOOTSTRAP] [debug] Attempting to clear using UiObject.clearText().

表示执行UiObject.clearText()来清除元素的现有文字

info: [debug] [BOOTSTRAP] [debug] Sending plain text to element: 中文first

表示在元素中输入中文first

info: [debug] [BOOTSTRAP] [debug] Returning result: {"value":true,"status":0}

表示 bootstrap 返回执行结果{"value":true,"status":0}

info: [debug] Responding to client with success: {"status":0,"value":true,"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990"}

表示 appium server 准备返回{"status":0,"value":true,"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990"}给发起请求的 client

info: <-- POST /wd/hub/session/be7c34f0-e12d-4368-9f80-c116e0cc3990/element/1/value 200 9119.841 ms - 76 {"status":0,"value":true,"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990"}

表示 appium server 返回 url 为/wd/hub/session/be7c34f0-e12d-4368-9f80-c116e0cc3990/element/1/value,http 状态码为 200,内容为{"status":0,"value":true,"sessionId":"be7c34f0-e12d-4368-9f80-c116e0cc3990"}的数据包给 client

提出疑问

通过分析,里面有几个让人在意的地方:

  1. 从 client 给 server 的请求中只有sessionIdidvalue三个参数,而 server 加入到工作序列的参数除了命令名称外还增加了replace参数(那个帖子中还有unicodeKeyboard参数),增加的replaceunicodeKeyboard参数到底是以什么为根据来确定的
  2. bootstrap 中获得的 setText 的 value 是first,为何实际在 bootstrap 输入的是中文first
  3. 实际测试中,当 appium server 返回操作成功的信息时(bootstrap 也显示输入了值),实际上此时应用中 editText 控件的内容为空,即清除内容后没有按照 log 所说输入中文first

带着这几个问题,我们开始分析及调试源码。

解决疑问

  • appium server 部分:

所有命令都是先通过 routing 分析来源来调用对应的方法。因此先查 routing:
appium/lib/server/routing.js

...
  rest.post('/wd/hub/session/:sessionId?/element/:elementId?/value', controller.setValue);
...
  rest.post('/wd/hub/session/:sessionId?/appium/element/:elementId?/value', controller.setValueImmediate);
  rest.post('/wd/hub/session/:sessionId?/appium/element/:elementId?/replace_value', controller.replaceValue);

找到三个和 serValue 相关的命令。通过 url 可以看出只有setValue方法是遵循 webdriver api 的,其它两个方法都是 appium 自己另外添加的。这里的controller方法的源码分别如下:

appium/lib/server/controller.js

...
exports.setValue = function (req, res) {
  var elementId = req.params.elementId
      // spec says value attribute is an array of strings;
      // let's turn it into one string
    , value = req.body.value.join('');

  req.device.setValue(elementId, value, getResponseHandler(req, res));
};

exports.replaceValue = function (req, res) {
  var elementId = req.params.elementId
      // spec says value attribute is an array of strings;
      // let's turn it into one string
    , value = req.body.value.join('');

  req.device.replaceValue(elementId, value, getResponseHandler(req, res));
};
...
exports.setValueImmediate = function (req, res) {
  var element = req.params.elementId
    , value = req.body.value;
  if (checkMissingParams(req, res, {element: element, value: value})) {
    req.device.setValueImmediate(element, value, getResponseHandler(req, res));
  }
};
...

此处req.device会根据平台不同使用不同的实现来执行。此处我们仅关注 android 平台,它的实现方法如下:

appium/lib/devices/android/android-controller.js

...
androidController.setValue = function (elementId, value, cb) {
  var params = {
    elementId: elementId,
    text: value,
    replace: false
  };
  if (this.args.unicodeKeyboard) {
    params.unicodeKeyboard = true;
  }
  this.proxy(["element:setText", params], cb);
};

androidController.replaceValue = function (elementId, value, cb) {
  var params = {
    elementId: elementId,
    text: value,
    replace: true
  };
  if (this.args.unicodeKeyboard) {
    params.unicodeKeyboard = true;
  }
  this.proxy(["element:setText", params], cb);
};
...
androidController.setValueImmediate = function (elementId, value, cb) {
  cb(new NotYetImplementedError(), null);
};
...

可以看到replaceunicodeKeyboard参数都是在这里加入的。其中setValue方法和replaceValue方法唯一区别是replace参数的值,unicodeKeyboard是根据 server 的unicodeKeyboard参数值 (就是启动 session 时的传入的unicodeKeyboard参数) 决定的。而setValueImmediate方法还没实现,因此不再探究。

至此,第一个疑问解决。repalceunicodeKeyboard是在appium/lib/devices/android/android-controller.js中根据调用的方法来设定的。其中setValuereplace参数值固定为false

bootstrap 部分

关于 bootstrap 源码的分析我主要参考了http://www.it165.net/pro/html/201407/17696.html,里面已经大致说明了setText方法的实现是放在bootstrap/src/io/appium/android/bootstrap/handler/SetText.java中:

appium/libs/devices/android/bootstrap/src/io/appium/android/bootstrap/handler/SetText.java

...
boolean replace = Boolean.parseBoolean(params.get("replace").toString());
String text = params.get("text").toString();
...
boolean unicodeKeyboard = false;
if (params.get("unicodeKeyboard") != null) {
  unicodeKeyboard = Boolean.parseBoolean(params.get("unicodeKeyboard").toString());
}
String currText = el.getText();
new Clear().execute(command);
if (!el.getText().isEmpty()) {
  // clear could have failed, or we could have a hint in the field
  // we'll assume it is the latter
  Logger.debug("Text not cleared. Assuming remainder is hint text.");
  currText = "";
}
if (!replace) {
  text = currText + text;
}
final boolean result = el.setText(text, unicodeKeyboard);
if (!result) {
  return getErrorResult("el.setText() failed!");
}
...
return getSuccessResult(result);
...

从这里看到,从获得命令到完成输入一共有以下步骤:

  1. 判断并存储replace, text, unicodeKeyboard参数的值
  2. 通过getText获取当前元素的文字,存到currText
  3. 使用new Clear().execute(command);清除当前元素的所有文字
  4. 再次获取当前元素文字。如果文字仍不为空,认定它是 hint text 并把currText置空(由于此处也有可能是 clear 方法出错导致没有 clear 成功,因此留了一个 log 说明假设还存在的 text 是 hint text)
  5. 如果replace不是 true,在text前面加入currText
  6. 调用setText方法执行实际输入。

在这里解答了第二个疑问:sendKeys 的文字会在前面加了 “中文” 这两个字符是因为它在第 5 步加入了元素原来的 text 内容。即采用追加方法来输入文本。

为何早期 appium 版本 (如 1.2.4) 没有采用追加,而现在采用追加 导致破坏了向下兼容性呢?答案是 早期版本的实现实际上是错的
webdriver api 中对于 sendKeys 方法的描述明确说明了 SendKeys 的实现应该是 在当前文本框的文字最后采用追加形式来输入文本要实现清除文本框内容后输入文本应该在脚本中采用先 Clear 后 SendKeys 的方式

到这里为止,已经基本解决了帖子中的问题。但对于第三个疑问,目前还没看到哪里出问题。而且 UIAutomator API 的 setText 方法并没有uincodeKeyboard这个参数,所以推测这里的setText并不是 UiObject 的 setText 方法。再来看看这里的el.setText(text, unicodeKeyboard)的实现:

appium/libs/devices/android/bootstrap/src/io/appium/android/bootstrap/AndroidElement.java

...
  AndroidElement(final String id, final UiObject el) {
    this.el = el;
    this.id = id;
  }
...
  public boolean setText(final String text, boolean unicodeKeyboard)
      throws UiObjectNotFoundException {
    if (unicodeKeyboard && UnicodeEncoder.needsEncoding(text)) {
      Logger.debug("Sending Unicode text to element: " + text);
      String encodedText = UnicodeEncoder.encode(text);
      Logger.debug("Encoded text: " + encodedText);
      return el.setText(encodedText);
    } else {
      Logger.debug("Sending plain text to element: " + text);
      return el.setText(text);
    }
  }
...

可以看到,这里的 el 是 UiAutomator 的 UiObject 对象了。然后根据setText函数看到输入文本的具体步骤:

  1. 判断unicodeKeyboard是否为true,如果是还要检查需要输入的文本是否需要进行 encode,两者均为true,用UnicodeEncoder.encode(text)把文本编码,然后再把编码后的文本发给 UiObject 的setText方法
  2. 如果不符合上面的条件,直接调用 UiObject 的setText方法。

咋看之下没啥问题。但我在调试过程中使用 Evaluate Expression 功能单独运行setText("中文"),结果 文本框没有输入任何值,但返回值为true,而setText("first")则能正常输入!

由此解决了第三个疑问,同时发现一个 bug:
当没有设定unicodeKeyboardtrue的情况下,直接使用 sendKeys 方法,当 editText 的默认值(非 hint text)含有非 ASCII 字符时,会遇到脚本没有报错,但实际上没有输入任何内容的情况
从用户角度,在unicodeKeyboardfalse情况下接收到含有无法输入的字符时,应该直接报错并说明无法输入。否则这个 bug 的性质就如同 你添加了一个记录,系统显示添加成功,但实际上没有添加进去这么坑爹。

从哪个版本开始 sendKeys 变成了追加:

经过在 appium 的 repo 中查询,查到把 sendKeys 改为追加的 commit 是:https://github.com/appium/appium/commit/f70ba32a76c486f0609c99c4074bac318fdc7195

这个 commit 存在于v1.2.2及以后的所有 tag 中,所以应该1.2.2以后的 appium 都变成了追加模式。至于帖子中为何说1.2.4还是替换模式,由于手上没有1.2.4的 appium,所以暂时还不清楚。

总结

  1. sendKeys在 webdriver API 中本来就是在文本后追加。Appium 早期版本(1.2.2 以前)错误地把它实现成了替换当前文本,在 1.2.2 后修复。
  2. 如果想实现替换当前文本,可以先使用clear方法清空文本,再使用sendKeys方法输入文本。
  3. 在不确定文本框默认值是否含有非 ASCII 字符前,如无特殊原因,测试 android 应用请尽量设置unicodeKeyboardtrue(关于 unicode 的具体说明详见https://github.com/testerhome/appium/blob/master/docs/cn/writing-running-appium/unicode.cn.md)。
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 16 条回复 时间 点赞

层层剖析,写的真是太好了。

这才是我们想要的文章啊~~~

32 个赞!

启动 APPIUM 的时候会把我的系统键盘默认设置能 appium 专门的键盘输入法!

#4 楼 @testly 设置了unicodeKeyboardtrue后会自动安装 appium 的输入法并设为默认输入法。你想在测试完成后自动改回原有输入法可以把resetKeyboard设为true
详见:https://github.com/appium/appium/blob/master/docs/cn/writing-running-appium/caps.cn.md

看到标题我第一个想法是不是有 clear 咩 然后再看到这么详细的解释 我就知道自己很傻很天真了 谢谢楼主的分享 :plus1:

感谢分享,期待更多这样的文章

appium 1.4.0 为什么 sendkeys 又是没有追加呢 没太懂

> info: --> POST /wd/hub/session/82a36e8f-5508-4429-888c-1961916a7e2e/element/4/value {"sessionId":"82a36e8f-5508-4429-888c-1961916a7e2e","id":"4","value":["h","e","l","o",".","e","v","e","r","y","o","n","e"]}
> info: [debug] Pushing command to appium work queue: ["element:setText",{"elementId":"4","text":"helo.everyone","replace":false,"unicodeKeyboard":true}]
> info: [debug] [BOOTSTRAP] [debug] Got data from client: {"cmd":"action","action":"element:setText","params":{"elementId":"4","text":"helo.everyone","replace":false,"unicodeKeyboard":true}}
> info: [debug] [BOOTSTRAP] [debug] Got command of type ACTION
> info: [debug] [BOOTSTRAP] [debug] Got command action: setText
> info: [debug] [BOOTSTRAP] [debug] Using element passed in.
> info: [debug] [BOOTSTRAP] [debug] Attempting to clear using UiObject.clearText().
> info: [debug] [BOOTSTRAP] [debug] Sending plain text to element: helo.everyone
> info: [debug] [BOOTSTRAP] [debug] Returning result: {"value":true,"status":0}
> info: [debug] Responding to client with success: {"status":0,"value":true,"sessionId":"82a36e8f-5508-4429-888c-1961916a7e2e"}
> info: <-- POST /wd/hub/session/82a36e8f-5508-4429-888c-1961916a7e2e/element/4/value 200 5542.413 ms - 76 {"status":0,"value":true,"sessionId":"82a36e8f-5508-4429-888c-1961916a7e2e"}
> info: --> POST /wd/hub/session/82a36e8f-5508-4429-888c-1961916a7e2e/element/4/value {"sessionId":"82a36e8f-5508-4429-888c-1961916a7e2e","id":"4","value":["s","s","s","s"]}
> info: [debug] Pushing command to appium work queue: ["element:setText",{"elementId":"4","text":"ssss","replace":false,"unicodeKeyboard":true}]
> info: [debug] [BOOTSTRAP] [debug] Got data from client: {"cmd":"action","action":"element:setText","params":{"elementId":"4","text":"ssss","replace":false,"unicodeKeyboard":true}}
> info: [debug] [BOOTSTRAP] [debug] Got command of type ACTION
> info: [debug] [BOOTSTRAP] [debug] Got command action: setText
> info: [debug] [BOOTSTRAP] [debug] Using element passed in.
> info: [debug] [BOOTSTRAP] [debug] Attempting to clear using UiObject.clearText().
> info: [debug] [BOOTSTRAP] [debug] Clearing text not successful. Attempting to clear by selecting all and deleting.
> info: [debug] [BOOTSTRAP] [debug] Clearing text not successful. Attempting to clear by sending delete keys.
> info: [debug] [BOOTSTRAP] [error] error while getting method injectInputEvent from class class com.android.uiautomator.core.UiAutomatorBridge with parameter types [class android.view.InputEvent, class java.lang.Boolean] injectInputEvent [class android.view.InputEvent, class java.lang.Boolean]
> info: [debug] [BOOTSTRAP] [debug] Text not cleared. Assuming remainder is hint text.
> info: [debug] [BOOTSTRAP] [debug] Sending plain text to element: ssss
> info: [debug] [BOOTSTRAP] [debug] Returning result: {"value":true,"status":0}
> info: [debug] Responding to client with success: {"status":0,"value":true,"sessionId":"82a36e8f-5508-4429-888c-1961916a7e2e"}

我个人的想法 连续 sendkey 以后 都是会调用 setText() 方法的,那为什么第二次 setText 的时候的那个不是 “helo.everyssss” 而只是 ‘ssss’ 求助。

#9 楼 @zsx10110 appium 的追加其实是先记录输入前的文字。输入时把记录的文字先输入,然后再输入 sendKeys 传入的文本。setText 方法本身只能替换,没带有追加功能。

从你的 Log 来看, clearText() 方法执行不成功,因此它假定没能删掉的文字是提示文字(hint text),即平时我们一旦在输入框输入内容后就会自动消失的文字。所以第二次 setText 时它认为留在输入框的文字(我估计就是你第一次 sendKeys 时输入的 helo.everyone )是会自动消失的,所以就没有再次输入它了。

#10 楼 @chenhengjie123
还是不太明白 clearText() 方法确实没有执行成功,但是我们这样子看

boolean replace = Boolean.parseBoolean(params.get("replace").toString());
String text = params.get("text").toString();
...
boolean unicodeKeyboard = false;
if (params.get("unicodeKeyboard") != null) {
  unicodeKeyboard = Boolean.parseBoolean(params.get("unicodeKeyboard").toString());
}
String currText = el.getText();
new Clear().execute(command);
if (!el.getText().isEmpty()) {
  // clear could have failed, or we could have a hint in the field
  // we'll assume it is the latter
  Logger.debug("Text not cleared. Assuming remainder is hint text.");
  currText = "";
}
if (!replace) {
  text = currText + text;
}
final boolean result = el.setText(text, unicodeKeyboard);
if (!result) {
  return getErrorResult("el.setText() failed!");
}
...
return getSuccessResult(result);

还是引用这段 setText 的代码,假设这个时候我的输入框的内容当前已经有个内容"hello"了 ,这个 “hello” 并不是 hint 文字,而是我上次输入的,那么从上面代码看 ,首先是 String currText = el.getText(); 首先是拿到文本的内容也就是 hello。 再来才是执行清除文本的命令, 之后判断 replace 如果为 false 的话,就 text = currText + text; 所以我觉得这个跟是否成功清除文本应该是没有关系的才对啊。
不知道我说的清楚不?

#10 楼 @chenhengjie123 额 我错了,我竟然没注意到

if (!el.getText().isEmpty()) {
          // clear could have failed, or we could have a hint in the field
          // we'll assume it is the latter
          Logger.debug("Text not cleared. Assuming remainder is hint text.");
          currText = "";
        }

不细心了 sorry 我拿一个原生的系统来验证下。

#10 楼 @chenhengjie123 你好,我问一下哈,我这边是直接在原有文字后面追加输入时,它会把原有的文字删除后 sendkeys,日志打印的和 9 楼的一样,按照你说的这个

从你的 Log 来看, clearText() 方法执行不成功,因此它假定没能删掉的文字是提示文字(hint text),即平时我们一旦在输入框输入内容后就会自动消失的文字。所以第二次 setText 时它认为留在输入框的文字(我估计就是你第一次 sendKeys 时输入的 helo.everyone )是会自动消失的,所以就没有再次输入它了。

所以如果我要追加输入时,是不是 sendkeys 要把前面文本框原有的内容也加入?

ps:我的 appium 版本是 1.4.16

#13 楼 @star8 不需要。sendkeys 的效果就是追加输入。

如果你想确保输入框的值就是 sendkeys 的内容,那得先 clear ,再 sendkeys 。

#14 楼 @chenhengjie123

这样我想要在后面输入 123456 时,它会把前面的话题删除掉,我代码是

driver.findElementById("com.m4399.gamecenter.plugin.main:id/zone_edit").sendKeys("123456");

appium 打印的日志

info: [debug] Pushing command to appium work queue: ["element:setText",{"elementId":"22","text":"123456","replace":false,"unicodeKeyboard":true}]
info: [debug] [BOOTSTRAP] [debug] Got data from client: {"cmd":"action","action":"element:setText","params":{"elementId":"22","text":"123456","replace":false,"unicodeKeyboard":true}}
info: [debug] [BOOTSTRAP] [debug] Got command of type ACTION
info: [debug] [BOOTSTRAP] [debug] Got command action: setText
info: [debug] [BOOTSTRAP] [debug] Using element passed in.
info: [debug] [BOOTSTRAP] [debug] Attempting to clear using UiObject.clearText().
info: [debug] [BOOTSTRAP] [debug] Clearing text not successful. Attempting to clear by selecting all and deleting.
info: [debug] [BOOTSTRAP] [debug] Clearing text not successful. Attempting to clear by sending delete keys.
info: [debug] [BOOTSTRAP] [debug] Text remains after clearing, but it appears to be hint text.
info: [debug] [BOOTSTRAP] [debug] Text not cleared. Assuming remainder is hint text.
info: [debug] [BOOTSTRAP] [debug] Sending plain text to element: 123456
info: [debug] [BOOTSTRAP] [debug] Returning result: {"status":0,"value":true}
info: [debug] Responding to client with success: {"status":0,"value":true,"sessionId":"b04c397a-2135-4793-b902-102038be5222"}

#14 楼 @chenhengjie123

从这里看到,从获得命令到完成输入一共有以下步骤:

  1. 判断并存储 replace, text, unicodeKeyboard 参数的值
  2. 通过 getText 获取当前元素的文字,存到 currText 中
  3. 使用 new Clear().execute(command);清除当前元素的所有文字
  4. 再次获取当前元素文字。如果文字仍不为空,认定它是 hint text 并把 currText 置空(由于此处也有可能是 clear 方法出错导致没有 clear 成功,因此留了一个 log 说明假设还存在的 text 是 hint text)
  5. 如果 replace 不是 true,在 text 前面加入 currText。
  6. 调用 setText 方法执行实际输入。

我这边是当清空当前元素文字时,还有 hint text 提示文字,这样就把 currText 置为空了,然后后面调用 setText 方法时,text 前面加入的 currText 就为空,导致效果是没有追加输入。

这种情况求解答啊🙏

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册