android espresso异常:PerformException的解决方案

PerformException Error performing 'single click - At Coordinates: x, y and precision: 16, 16' on view 'Animations or transitions are enabled on the target device

这是安卓自动化测试经常莫名其妙遇到的一个报错,通过开启手机的指针位置,发现确实是点击到了对应的 view 上,但是没有触发其点击事件,然后报错。

这个报错通常有几个简单的原因,如下:

1、没有关闭动画

2、页面上弹出了软键盘,阻挡了

3、测试用例代码本身有问题。

如果是以上三个原因,就简单了,那其实这也不是事了。问题就是一切正常,莫名其妙就报这个错。其内部具体什么原因导致的,我也没有查清楚,网上搜了很多,都不得其要,包括git上的几个问题,感觉也作用不到。当时通过测试发现,报这个错误时,有时和 view 所在的位置有关,我的用例中就是屏幕底部区域就会有问题,也不知什么原因,仅做参考吧。

还是结论先行:

因为点击是没有问题的,所以

1、捕获这个异常,

2、然后找出触发此次 ViewAction 的 ViewMatcher,根据 matcher 找到当前页面的 view,

3、直接触发这个 view 设置的 OnClickListener ,用例继续往下执行。

示例:

比如 代码 onView(withId(R.id.go_tv)).perform(click()) 出现 PerformException 报错,这时候会 try-catch 住这个异常,当前的 Matcher<View> 就是 withId(R.id.go_tv) ,根据这个从当前页面查找出目标view goTv,然后调用 goTv.callOnClick(),直接触发其监听器的 onClick() 方法。至此,用例便可以继续执行下去。

为什么不是直接根据 id去页面上 find呢?

因为如果根据 id 的话,一方面就只能根据 id 去找了,有的地方通过文字或其他的条件匹配的就歇菜了。另外如果页面是 addView 的,相同 id 可能还有多个。而根据 matcher 符合测试用例的匹配条件,最终查出的 view 不会有错。

方案:

1、根据 matcher 查找 View

class PerformExceptionRescuer(id: Int) {

    constructor(matcher: Matcher<View>?) : this(0) {
        itemMatch = matcher
    }

    private var itemMatch: Matcher<View>? = null

    init {
        if (id > 0) {
            itemMatch = withId(id)
        }
    }

    fun tryClick() {
        val m: TypeSafeMatcher<View> = getMatcher()
        Espresso.onView(m).perform(EmptyAction())
    }

    private fun getMatcher(): TypeSafeMatcher<View> {
        val m: TypeSafeMatcher<View> = object : TypeSafeMatcher<View>() {

            private var find = false

            override fun describeTo(description: Description) {
                //正常的时候,在 androidx.test.espresso.ViewInteraction.doPerform
                // 方法中会 Log.i() 打印出这条信息,所以控制台有这个并不一定是失败
                description.appendText("PerformExceptionRescuer:\n 用例发生 PerformException 异常, 尝试直接触发 点击事件.")
            }

            public override fun matchesSafely(view: View): Boolean {
                if (!find && itemMatch?.matches(view) == true) {
                    find = true
//                    view.performClick()//这个会模拟点击
//                    view.callOnClick()//这个直接调用 click 方法
                    Log.i("PerformExceptionRescuer", "找到 view ${view.javaClass.simpleName}")
                    return true
                }
                return false
            }
        }
        return m
    }

   
}

好吧,还是提供了一个面向 id 的构造方法(其实是作为测试和手动处理重试用的)。itemMatch 由外部传进来,核心代码就是 getmatcher() 中的

public override fun matchesSafely(view: View): Boolean {
                if (!find && itemMatch?.matches(view) == true) {
                    find = true
//                    view.performClick()//这个会模拟点击
//                    view.callOnClick()//这个直接调用 click 方法
                    Log.i("PerformExceptionRescuer", "找到 view ${view.javaClass.simpleName}")
                    return true
                }
                return false
            }

matcheSafely 会对页面所有的 view 进行遍历,对我们来说,此时已经没必要了。只要找到一个符合的 view 就可以了,如果真的会找到多个,那说明测试用例那这一部分写的代码就是有问题。所以找到一个 view 后直接 return true,并忽略此后的其他 view 了。

那这个找到的 view 在哪呢,我们接着看对 view 的点击处理 EmptyAction

private inner class EmptyAction : ViewAction {
        override fun getConstraints(): Matcher<View> {
            return ViewMatchers.isDisplayed()
        }

        override fun getDescription(): String {
            return "EmptyAction"
        }

        override fun perform(uiController: UiController?, view: View?) {
            //把调用逻辑放在这里感觉更符合逻辑
            if (view?.hasOnClickListeners() == true) {
                view.callOnClick()
            } else {
                throw RuntimeException("PerformExceptionRescuer: 根据条件 $itemMatch 找到的view 没有设置点击事件,请匹配代码中真正设置了 OnClickListener 的 View")
            }
        }
    }

 ViewAction 应该不陌生吧,测试用例里面的各种执行动作都是 viewAction, 比如 click()

这个我们就简单的继承就好,按照测试用例的框架原理来做。

perform() 方法中的 View 参数就是我们上面找到的那个 view ,就是在它上面执行 click 报的 PerformException,

所以这里我们拿到了 view,直接调用其 callOnCLick() 即可。这里为了严谨,可以先判断一下 view是否设置了 OnCLickListener,另外调的是 callOnCLick() 方法,而不是 perfromClick(),区别在于前者是直接调 listener 的 onClick(),而后者还会去模拟点击o(╥﹏╥)o。

至于为什么要判断是否已设置监听了呢?

写过用例都知道,我们写的用例代码可能点的并不是真正有监听事件的view,点在其子view上也行,但是这种情况在这就不会起作用。

比如 onView(withId(R.id.go_tv)).perform(click()),其实真正有点击事件的是它的父view go_ll,执行用例时能跑过,但是我们在这会调 go_tv 的 callOnCLick() ,这时候就是没用的,晓得了吧。

怎么使用呢,一行 PerformExceptionRescuer(viewMatcher).tryClick()即可。

那么问题又来了,我们在哪里调用这句代码呢?

2、自定义异常处理器 FailureHandler

首先,看一下 PerfromException 是在哪里抛出的呢,跟随 onView(withId(R.id.go_tv)).perform(click()) 的 perfrom 方法,最终可以来到 ViewInteraction 类的waitForAndHandleInteractionResults 方法:

private void waitForAndHandleInteractionResults(List<ListenableFuture<Void>> interactions) {
    try {
      // Blocking call
      InteractionResultsHandler.gatherAnyResult(interactions);
    } catch (RuntimeException ee) {
      failureHandler.handle(ee, viewMatcher);
    } catch (Error error) {
      failureHandler.handle(error, viewMatcher);
    } finally {
      uiController.interruptEspressoTasks();
    }
  }

出现异常时,都是由 failureHandler.handle 来处理的,继续看一下这个方法,是在实现类 DefaultFailureHandler 的 handle方法:

@Override
  public void handle(Throwable error, Matcher<View> viewMatcher) {
    if (error instanceof EspressoException
        || error instanceof AssertionFailedError
        || error instanceof AssertionError) {
      throwIfUnchecked(getUserFriendlyError(error, viewMatcher));
      throw new RuntimeException(getUserFriendlyError(error, viewMatcher));
    } else {
      throwIfUnchecked(error);
      throw new RuntimeException(error);
    }
  }

而 PerfromException 是在 getUserFriendlyError 方法中返回的:

private Throwable getUserFriendlyError(Throwable error, Matcher<View> viewMatcher) {
    if (error instanceof PerformException) {
      StringBuilder sb = new StringBuilder();
      if (!isAnimationAndTransitionDisabled(appContext)) {
        sb.append(
            "Animations or transitions are enabled on the target device.\n"
                + "For more info check: http://goo.gl/qVu1yV\n\n");
      }
      sb.append(viewMatcher.toString());
      // Re-throw the exception with the viewMatcher (used to locate the view) as the view
      // description (makes the error more readable). The reason we do this here: not all creators
      // of PerformException have access to the viewMatcher.
      throw new PerformException.Builder()
          .from((PerformException) error)
          .withViewDescription(sb.toString())
          .build();
    }

看看这些文案,是不是很熟悉。

"Animations or transitions are enabled on the target device.\n"
                + "For more info check: http://goo.gl/qVu1yV\n\n");

getUserFriendlyError

看这个方法的名字,getUserFriendlyError,返回一个用户友好的错误。意思就是返回一些对我们有帮助的,能看得懂的错误信息。如果你搜过其他网页,看过 git 上的 issue,这里就会会心一笑。因为之前报 PerfromException 错误时,可能是一些其他的问题导致的程序崩溃,但是最后会导致用例执行不下去。这些实际的情况被略掉了,统统都直接反的PerfromException错误信息,不利于开发者排查问题。所以这个方法会 new 一个新的 PerfromException 对象,把一些有用的信息都加进来。

好了,在从这回到 ViewInteraction 类的 waitForAndHandleInteractionResults 方法,总结一下,实际抛异常时,PerfromException 会被封装成一个 RuntimeException 类型的异常,PerfromException 是作为它的一个 cause 的。

知道了异常在哪抛出,我们能插入的点就是在 failureHandler.handle(ee, viewMatcher) 处,这里即是 catch 住异常的地方,又有 viewMatcher,看起来很棒的。

值得庆幸的是 ViewInteraction 提供了一个设置自定义 failureHandler 的方法,我们可以设置一个自己的 failureHandler,这样 此处的 failureHandler.handle(ee, viewMatcher) 就可以换成我们自己的 myFailureHandler.handle(ee, viewMatcher)

/**
   * Replaces the default failure handler (@see Espresso.setFailureHandler) with a custom
   * failurehandler for this particular interaction.
   *
   * @param failureHandler a non-null failurehandler to use to report failures.
   * @return this interaction for further perform/verification calls.
   */
  public ViewInteraction withFailureHandler(FailureHandler failureHandler) {
    this.failureHandler = checkNotNull(failureHandler);
    return this;
  }

原理就是这样

直接看自定义的CustomFailureHandler类:

class CustomFailureHandler(private var source: FailureHandler) : FailureHandler {
        override fun handle(error: Throwable?, viewMatcher: Matcher<View>?) {
            try {
                source.handle(error, viewMatcher)
            } catch (e: Exception) {
                if (e.cause is PerformException) {
                    PerformExceptionRescuer(viewMatcher).tryClick()
                } else {
                    throw e
                }
            }
        }
    }

source就是系统自己的 failureHandler,他里面有很多逻辑,我们没有必要去动,并且在无法处理异常时,也应该按原来的样子输出错误信息。所以实际上我们还是调用了系统的 failureHandler,然后判断如果是 PerfromException,就把异常拦下来,调用这句PerformExceptionRescuer(viewMatcher).tryClick(),去尝试直接触发点击监听。

3、设置自定义 failureHandler:

companion object {
        fun setFailureHandler() {
            val source = DefaultFailureHandler(InstrumentationRegistry.getInstrumentation().targetContext)
            Espresso.setFailureHandler(CustomFailureHandler(source))
        }
    }

至于 source 为啥是这样创建的,有心跟一下源码会发现,系统自带的 failureHandler

就是这样创建出来的,这里就不铺开了。所以我们直接手动创建一个和系统自带的 failureHandler一样的对象,塞进自定义 CustomFailureHandler 即可。

完事具备,只欠东风了:替换 FailureHandler。

在用例执行之前,替换一下就行

    @Before
    fun config() {
       
        PerformExceptionRescuer.setFailureHandler()

    }

不要落下 @Before

ok了。

猜你喜欢

转载自blog.csdn.net/xx23x/article/details/126270550