Improve the actual and perceived speed of thread loading (#3118)

* Improve the actual and perceived speed of thread loading

To improve the actual speed, note that if the user has opened a thread from
their home timeline then the initial status is cached in the database. Other
statuses in the same thread may be cached as well.

So try and load the initial status from the database, falling back to the
network if it's not present (e.g., the user has opened a thread from the
local or federated timelines, or a search).

Introduce a new loading state to deal with this case.

In typical cases this allows the UI to display the initial status immediately
with no need to show a progress indicator.

To improve the perceived speed, delay showing the initial loading circular
progress indicators by 500ms. If loading the initial status completes within
that time no spinner is shown and the user will perceive the action as
close-to-immediate
(https://www.nngroup.com/articles/response-times-3-important-limits/).

Additionally, introduce an extra indeterminate progress indicator.

The new indicator is linear, anchored to the bottom of the screen, and shows
progress loading ancestor/descendant statuses. Like the other indicator is
also delayed 500ms from when ancestor/descendant status information is
fetched, and if the fetch completes in that time it will not be shown.

* Mark `getStatus` as suspend so it doesn't run on the main thread

* Save an allocation, use an isDetailed parameter to TimelineStatusWithAccount.toViewData

Rename Status.toViewData's "detailed" parameter to "isDetailed" for
consistency with other uses.

* Ensure suspend functions run to completion when testing

* Delay-load the status from the network even if it's cached

This speeds up the UI while ensuring it will eventually contain accurate data
from the remote.

* Load the network status before updating the list

Avoids excess animations if the network copy has changes

* Fix UI flicker when loading reblogged statuses

* Lint

* Fixup tests
This commit is contained in:
Nik Clayton 2023-01-09 21:31:31 +01:00 committed by GitHub
commit c650ca9362
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 331 additions and 110 deletions

View file

@ -1,8 +1,12 @@
package com.keylesspalace.tusky.components.viewthread
import android.os.Looper.getMainLooper
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import at.connyduck.calladapter.networkresult.NetworkResult
import com.google.gson.Gson
import com.keylesspalace.tusky.appstore.BookmarkEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.FavoriteEvent
@ -11,14 +15,18 @@ import com.keylesspalace.tusky.components.timeline.mockStatus
import com.keylesspalace.tusky.components.timeline.mockStatusViewData
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.Converters
import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.network.FilterModel
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.doReturn
@ -35,9 +43,38 @@ class ViewThreadViewModelTest {
private lateinit var api: MastodonApi
private lateinit var eventHub: EventHub
private lateinit var viewModel: ViewThreadViewModel
private lateinit var db: AppDatabase
private val threadId = "1234"
/**
* Execute each task synchronously.
*
* If you do not do this, and you have code like this under test:
*
* ```
* fun someFunc() = viewModelScope.launch {
* _uiState.value = "initial value"
* // ...
* call_a_suspend_fun()
* // ...
* _uiState.value = "new value"
* }
* ```
*
* and a test like:
*
* ```
* someFunc()
* assertEquals("new value", viewModel.uiState.value)
* ```
*
* The test will fail, because someFunc() yields at the `call_a_suspend_func()` point,
* and control returns to the test before `_uiState.value` has been changed.
*/
@get:Rule
val instantTaskRule = InstantTaskExecutorRule()
@Before
fun setup() {
shadowOf(getMainLooper()).idle()
@ -56,7 +93,19 @@ class ViewThreadViewModelTest {
isActive = true
)
}
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager)
val context = InstrumentationRegistry.getInstrumentation().targetContext
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.addTypeConverter(Converters(Gson()))
.allowMainThreadQueries()
.build()
val gson = Gson()
viewModel = ViewThreadViewModel(api, filterModel, timelineCases, eventHub, accountManager, db, gson)
}
@After
fun closeDb() {
db.close()
}
@Test
@ -68,13 +117,13 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
revealButton = RevealButtonState.REVEAL,
refreshing = false
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL
),
viewModel.uiState.first()
)
@ -84,7 +133,7 @@ class ViewThreadViewModelTest {
@Test
fun `should emit status even if context fails to load`() {
api.stub {
onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1"))
onBlocking { status(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1"))
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException())
}
@ -93,11 +142,11 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true)
),
detailedStatusPosition = 0,
revealButton = RevealButtonState.NO_BUTTON,
refreshing = false
),
viewModel.uiState.first()
)
@ -107,7 +156,7 @@ class ViewThreadViewModelTest {
@Test
fun `should emit error when status and context fail to load`() {
api.stub {
onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException())
onBlocking { status(threadId) } doReturn NetworkResult.failure(IOException())
onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException())
}
@ -124,7 +173,7 @@ class ViewThreadViewModelTest {
@Test
fun `should emit error when status fails to load`() {
api.stub {
onBlocking { statusAsync(threadId) } doReturn NetworkResult.failure(IOException())
onBlocking { status(threadId) } doReturn NetworkResult.failure(IOException())
onBlocking { statusContext(threadId) } doReturn NetworkResult.success(
StatusContext(
ancestors = listOf(mockStatus(id = "1")),
@ -153,13 +202,13 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", isExpanded = true)
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.HIDE,
refreshing = false
),
viewModel.uiState.first()
)
@ -177,13 +226,13 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test", favourited = false),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
@ -201,13 +250,13 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", reblogged = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
@ -225,13 +274,13 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test", bookmarked = false)
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
@ -249,12 +298,12 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
@ -275,13 +324,13 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isExpanded = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
@ -302,13 +351,13 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isCollapsed = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
@ -329,13 +378,13 @@ class ViewThreadViewModelTest {
runBlocking {
assertEquals(
ThreadUiState.Success(
statuses = listOf(
statusViewData = listOf(
mockStatusViewData(id = "1", spoilerText = "Test"),
mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test", isShowingContent = true),
mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")
),
detailedStatusPosition = 1,
revealButton = RevealButtonState.REVEAL,
refreshing = false
),
viewModel.uiState.first()
)
@ -344,7 +393,7 @@ class ViewThreadViewModelTest {
private fun mockSuccessResponses() {
api.stub {
onBlocking { statusAsync(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1", spoilerText = "Test"))
onBlocking { status(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1", spoilerText = "Test"))
onBlocking { statusContext(threadId) } doReturn NetworkResult.success(
StatusContext(
ancestors = listOf(mockStatus(id = "1", spoilerText = "Test")),