Как отключить Spring Animation для эспрессо-тестов?
Я использую весеннюю анимацию Android в своем проекте (см. Здесь). Тем не менее, эти анимации мешают моим тестам эспрессо.
Я уже пытался отключить эти анимации с помощью параметров разработчика в телефоне, но, похоже, эти параметры не влияют на них.
Есть ли способ, как я могу отключить их только для испытаний?
1 ответ
После борьбы с ненадежным тестом из-за SpringAnimations я нашел три решения:
Решение 1. Добавьте функцию, которая завершает создание SpringAnimations.
Это наиболее инвазивный с точки зрения изменения существующего кода, но наименее сложный метод:
Вы можете проверить, отключена ли анимация во время выполнения:
fun animationsDisabled() =
Settings.Global.getFloat(
contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f,
) == 0.0f
Затем выборочно верните фиктивную анимацию, которая сразу же завершится, а также установив значение в ее конечное состояние:
fun <K : View?> createAnimation(
target: K,
property: FloatPropertyCompat<K>,
finalValue: Float
) = if (animationsDisabled() == false) {
SpringAnimation(target, property, finalValue).apply {
spring.dampingRatio = dampingRatio
spring.stiffness = stiffness
}
} else {
property.setValue(target, finalValue)
SpringAnimation(FloatValueHolder(0f)).apply{
spring = SpringForce(100f)
spring.dampingRatio = dampingRatio
spring.stiffness = stiffness
addUpdateListener { _, _, _ -> skipToEnd() }
}
}
}
Решение 2. Создайте IdlingResource, который сообщает Espresso, работает ли DynamicAnimation.
иFlingAnimation
оба расширяются от , класса, который игнорирует системный масштаб анимации и вызывает здесь проблемы.
Это решение не самое красивое, поскольку оно использует отражение, но детали реализации, на которые оно опирается, не изменились с тех пор.DynamicAnimation
был представлен.
На основеDataBindingIdlingResource
:
import android.view.View
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.test.espresso.IdlingResource
import androidx.test.ext.junit.rules.ActivityScenarioRule
import java.util.UUID
// An espresso idling resource implementation that reports idle status for all DynamicAnimation instances
class DynamicAnimationIdlingResource(private val activityScenarioRule: ActivityScenarioRule<*>) :
IdlingResource {
// list of registered callbacks
private val idlingCallbacks = mutableListOf<IdlingResource.ResourceCallback>()
// give it a unique id to workaround an espresso bug where you cannot register/unregister
// an idling resource w/ the same name.
private val id = UUID.randomUUID().toString()
// holds whether isIdle is called and the result was false. We track this to avoid calling
// onTransitionToIdle callbacks if Espresso never thought we were idle in the first place.
private var wasNotIdle = false
override fun getName() = "DynamicAnimation $id"
override fun isIdleNow(): Boolean {
val idle = !getDynamicAnimations().any { it.isRunning }
@Suppress("LiftReturnOrAssignment")
if (idle) {
if (wasNotIdle) {
// notify observers to avoid espresso race detector
idlingCallbacks.forEach { it.onTransitionToIdle() }
}
wasNotIdle = false
} else {
wasNotIdle = true
activityScenarioRule.scenario.onActivity {
it.findViewById<View>(android.R.id.content)
.postDelayed({ isIdleNow }, 16)
}
}
return idle
}
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
idlingCallbacks.add(callback)
}
/**
* Find all binding classes in all currently available fragments.
*/
private fun getDynamicAnimations(): List<DynamicAnimation<*>> {
val dynamicAnimations = mutableListOf<DynamicAnimation<*>>()
val animationHandlerClass = Class
.forName("androidx.dynamicanimation.animation.AnimationHandler")
val animationHandler =
animationHandlerClass
.getDeclaredMethod("getInstance")
.invoke(null)
val animationCallbacksField =
animationHandlerClass
.getDeclaredField("mAnimationCallbacks").apply {
isAccessible = true
}
val animationCallbacks =
animationCallbacksField.get(animationHandler) as ArrayList<*>
animationCallbacks.forEach {
if (it is DynamicAnimation<*>) {
dynamicAnimations.add(it)
}
}
return dynamicAnimations
}
}
Для удобства соответствующее тестовое правило:
/**
* A JUnit rule that registers an idling resource for all animations that use DynamicAnimations.
*/
class DynamicAnimationIdlingResourceRule(activityScenarioRule: ActivityScenarioRule<*>) : TestWatcher() {
private val idlingResource = DynamicAnimationIdlingResource(activityScenarioRule)
override fun finished(description: Description?) {
IdlingRegistry.getInstance().unregister(idlingResource)
super.finished(description)
}
override fun starting(description: Description?) {
IdlingRegistry.getInstance().register(idlingResource)
super.starting(description)
}
}
Это не идеальное решение, поскольку оно все равно заставит ваши тесты ждать анимации, несмотря на глобальное изменение масштаба анимации.
Если у вас есть бесконечные анимации, основанные на SpringAnimations(с установкой Damping на ноль), это не сработает, так как Espresso всегда будет сообщать о том, что анимация запущена. Вы можете обойти это, приведя DynamicAnimation к SpringAnimation и проверив, установлено ли демпфирование, но я чувствовал, что это достаточно редкий случай, чтобы не усложнять ситуацию.
Решение 3. Заставьте все SpringAnimations переходить к последнему кадру
Еще одно решение на основе отражения, но оно полностью отключает SpringAnimations. Компромисс заключается в том, что теоретически Espresso все еще может пытаться взаимодействовать в окне 1 кадра между запросом на завершение SpringAnimation и его фактическим завершением.
На практике мне приходилось повторять тест сотни раз подряд, чтобы это произошло, и в этот момент анимация может даже не быть источником нестабильности. Таким образом, компромисс, вероятно, того стоит, если анимация замедляет выполнение ваших тестов:
private fun disableSpringAnimations() {
val animationHandlerClass = Class
.forName("androidx.dynamicanimation.animation.AnimationHandler")
val animationHandler =
animationHandlerClass
.getDeclaredMethod("getInstance")
.invoke(null)
val animationCallbacksField =
animationHandlerClass
.getDeclaredField("mAnimationCallbacks").apply {
isAccessible = true
}
CoroutineScope(Dispatchers.IO).launch {
while (true) {
withContext(Dispatchers.Main) {
val animationCallbacks =
animationCallbacksField.get(animationHandler) as ArrayList<*>
animationCallbacks.forEach {
val animation = it as? SpringAnimation
if (animation?.isRunning == true && animation.canSkipToEnd()) {
animation.skipToEnd()
animation.doAnimationFrame(100000L)
}
}
}
delay(16L)
}
}
}
Вызовите этот метод в своем@Before
аннотированную функцию, чтобы запускать ее перед каждым тестом.
вSpringAnimation
выполнение,skipToEnd
устанавливает флаг, который не проверяется до следующего вызоваdoAnimationFrame
, следовательноanimation.doAnimationFrame(100000L)
вызов.