blob: a5f1f79d32226e85462f7422de9d088959554fbf [file] [log] [blame]
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.ui
import android.util.Log
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MonotonicFrameClock
import androidx.compose.runtime.Recomposer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.testutils.ComposeTestCase
import androidx.compose.testutils.createAndroidComposeBenchmarkRunner
import androidx.compose.ui.platform.AndroidUiDispatcher
import androidx.compose.ui.viewinterop.AndroidView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
import java.text.NumberFormat
import java.util.Locale
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@LargeTest
@RunWith(AndroidJUnit4::class)
class MemoryLeakTest {
@get:Rule
@Suppress("DEPRECATION")
val activityTestRule = androidx.test.rule.ActivityTestRule(ComponentActivity::class.java)
@Test
@SdkSuppress(minSdkVersion = 22) // b/266743031
fun disposeAndRemoveOwnerView_assertViewWasGarbageCollected() = runBlocking {
class SimpleTestCase : ComposeTestCase {
@Composable
override fun Content() {
// The following line adds coverage for delayed coroutine memory leaks.
LaunchedEffect(Unit) { delay(10000) }
Column {
repeat(3) {
Box {
BasicText("Hello")
}
}
}
}
}
val testCaseFactory = { SimpleTestCase() }
// NOTE: When fixing / debugging issues caused by this test it does not necessary has to be
// a real memory leak. It can happen that you've scheduled resource clean up in a delayed
// callback. But because we take over the main thread no callbacks get dispatched. This is
// still issue for benchmarks though, as they need to fully occupy the main thread. You can
// add check on main looper and perform clean asap if you are on main thread.
withContext(AndroidUiDispatcher.Main) {
val runner = createAndroidComposeBenchmarkRunner(
testCaseFactory,
activityTestRule.activity
)
try {
// Unfortunately we have to ignore the first run as it seems that even though the view
// gets properly garbage collected there are some data that remain allocated. Not sure
// what is causing this but could be some static variables.
loopAndVerifyMemory(iterations = 400, gcFrequency = 40, ignoreFirstRun = true) {
try {
runner.createTestCase()
runner.emitContent()
} finally {
// This will remove the owner view from the hierarchy
runner.disposeContent()
}
}
} finally {
runner.close()
}
}
}
@SdkSuppress(minSdkVersion = 22) // b/266743031
@Test
fun disposeContent_assertNoLeak() = runBlocking(AndroidUiDispatcher.Main) {
// We have to ignore the first run because `dispose` leaves the OwnerView in the
// View hierarchy to reuse it next time. That is probably not the final desired behavior
val emptyView = View(activityTestRule.activity)
loopAndVerifyMemory(iterations = 400, gcFrequency = 40) {
activityTestRule.activity.setContent {
Column {
repeat(3) {
Box {
BasicText("Hello")
}
}
}
}
// This replaces the Compose view, disposing its composition.
activityTestRule.activity.setContentView(emptyView)
}
}
@Test
fun memoryCheckerTest_noAllocationsExpected() = runBlocking {
// This smoke test checks that we don't give false alert and run all the iterations
var i = 0
loopAndVerifyMemory(200, 10) {
i++
}
assertThat(i).isEqualTo(200)
}
@Test(expected = AssertionError::class)
fun memoryCheckerTest_errorExpected(): Unit = runBlocking {
// This smoke test simulates memory leak and verifies that it was found
val data = mutableListOf<IntArray>()
loopAndVerifyMemory(10, 2) {
val array = IntArray(256 * 1024) // Allocate 4 * 256 KiB => 1 MiB
data.add(array)
}
// Just to avoid code being stripped away as unused.
val totalSum = data.map { array -> array.sum() }.sum()
Log.d("memoryCheckerTest", totalSum.toString())
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun recreateAndroidView_assertNoLeak() = runBlocking(AndroidUiDispatcher.Main) {
val immediateClock = object : MonotonicFrameClock {
override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R): R {
yield()
return onFrame(0L)
}
}
val context = coroutineContext + immediateClock
val recomposer = Recomposer(context)
suspend fun doFrame() {
Snapshot.sendApplyNotifications()
var pendingCount = 0
while (recomposer.hasPendingWork) {
pendingCount++
yield()
if (pendingCount == 10) {
error("Recomposer still pending work after 10 frames.")
}
}
}
var compose by mutableStateOf(false)
activityTestRule.activity.setContent(recomposer) {
if (compose) {
AndroidView(factory = {
object : View(it) { val alloc = List(1024) { 0 } }
})
}
}
launch(context = context, start = CoroutineStart.UNDISPATCHED) {
recomposer.runRecomposeAndApplyChanges()
}
doFrame()
loopAndVerifyMemory(ignoreFirstRun = true, iterations = 400, gcFrequency = 40) {
// Add AndroidView into the composition
compose = true
doFrame()
// This removes the AndroidView
compose = false
doFrame()
}
recomposer.cancel()
recomposer.join()
}
/**
* Runs the given code in a loop for exactly [iterations] times and every [gcFrequency] it will
* force garbage collection and check the allocated heap size.
* Suspending so that we can briefly yield() to the dispatcher before collecting garbage
* so that event loop driven cleanup processes can run before we take measurements.
*/
suspend fun loopAndVerifyMemory(
iterations: Int,
gcFrequency: Int,
ignoreFirstRun: Boolean = false,
operationToPerform: suspend () -> Unit
) {
val rawStats = ArrayList<Long>(iterations / gcFrequency)
// Collect data
repeat(iterations) { i ->
if (i % gcFrequency == 0) {
// Let any scheduled cleanup processes run before we take measurements
yield()
Runtime.getRuntime().let {
it.gc() // Run gc
rawStats.add(it.totalMemory() - it.freeMemory()) // Collect memory info
}
}
operationToPerform()
}
fun Long.formatMemory(): String {
return NumberFormat.getNumberInstance(Locale.US).format(this / 1024) + " KiB"
}
// Throw away the first run if needed
val memoryStats = if (ignoreFirstRun) rawStats.drop(1) else rawStats
val formattedStats = memoryStats.joinToString(", ") { it.formatMemory() }
// Verify that memory did not grow
val min = memoryStats.minOrNull()
val max = memoryStats.maxOrNull()
if (min == null || max == null) {
throw AssertionError("Collected memory data are corrupted")
}
// Check if every iteration the memory grew => that's a bad sign
val diffs = memoryStats
.zipWithNext()
.map { (it.second - it.first) / 1024 }
val areAllDiffsGrowing = diffs.all { it > 0 }
if (areAllDiffsGrowing) {
throw AssertionError(
"Possible memory leak detected!. Memory kept " +
"increasing every step. Min: ${min.formatMemory()}, max: " +
"${max.formatMemory()}\nData: [$formattedStats]"
)
}
// Check if we have a significant diff across all the data
val diff = max - min
if (diff > 1024 * 1024) { // 1 MiB tolerance
throw AssertionError(
"Possible memory leak detected! Min: " +
"${min.formatMemory()}, max: ${max.formatMemory()}\n" +
"Data: [$formattedStats]"
)
}
Log.i("MemoryTest", "Measured memory data: $formattedStats")
}
}