Как отключить 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)вызов.