Timeline: first version of diff/cache

This commit is contained in:
ganfra 2022-12-06 13:17:33 +01:00
parent e10c94b654
commit 81d2ca02c2
6 changed files with 157 additions and 28 deletions

View file

@ -18,6 +18,7 @@ dependencies {
implementation(libs.timber) implementation(libs.timber)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.androidx.recyclerview)
implementation("org.jsoup:jsoup:1.15.3") implementation("org.jsoup:jsoup:1.15.3")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.3") androidTestImplementation("androidx.test.ext:junit:1.1.3")

View file

@ -1,7 +1,10 @@
package io.element.android.x.features.messages package io.element.android.x.features.messages
import androidx.recyclerview.widget.DiffUtil
import io.element.android.x.designsystem.components.avatar.AvatarData import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize import io.element.android.x.designsystem.components.avatar.AvatarSize
import io.element.android.x.features.messages.diff.CacheInvalidator
import io.element.android.x.features.messages.diff.MatrixTimelineItemsDiffCallback
import io.element.android.x.features.messages.model.AggregatedReaction import io.element.android.x.features.messages.model.AggregatedReaction
import io.element.android.x.features.messages.model.MessagesItemGroupPosition import io.element.android.x.features.messages.model.MessagesItemGroupPosition
import io.element.android.x.features.messages.model.MessagesItemReactionState import io.element.android.x.features.messages.model.MessagesItemReactionState
@ -12,44 +15,92 @@ import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.room.MatrixRoom import io.element.android.x.matrix.room.MatrixRoom
import io.element.android.x.matrix.timeline.MatrixTimelineItem import io.element.android.x.matrix.timeline.MatrixTimelineItem
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.matrix.rustcomponents.sdk.FormattedBody import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.MessageFormat import org.matrix.rustcomponents.sdk.MessageFormat
import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.TimelineKey import timber.log.Timber
import kotlin.system.measureTimeMillis
class MessageTimelineItemStateFactory( class MessageTimelineItemStateFactory(
private val client: MatrixClient, private val client: MatrixClient,
private val room: MatrixRoom, private val room: MatrixRoom,
private val dispatcher: CoroutineDispatcher, private val dispatcher: CoroutineDispatcher,
) { ) {
private val timelineItemCaches = arrayListOf<MessagesTimelineItemState?>()
private var currentSnapshot: List<MatrixTimelineItem> = emptyList()
private val lock = Mutex()
private val cacheInvalidator = CacheInvalidator(timelineItemCaches)
suspend fun create( suspend fun create(
timelineItems: List<MatrixTimelineItem>, timelineItems: List<MatrixTimelineItem>,
): List<MessagesTimelineItemState> = ): List<MessagesTimelineItemState> =
withContext(dispatcher) { withContext(dispatcher) {
val messagesTimelineItemState = ArrayList<MessagesTimelineItemState>() lock.withLock {
for (index in timelineItems.indices.reversed()) { calculateAndApplyDiff(timelineItems)
val currentTimelineItem = timelineItems[index] getOrCreateFromCache(timelineItems)
val timelineItemState = when (currentTimelineItem) {
is MatrixTimelineItem.Event -> {
buildMessageEvent(
currentTimelineItem,
index,
timelineItems,
)
}
is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual(
"virtual_item_$index"
)
MatrixTimelineItem.Other -> continue
}
messagesTimelineItemState.add(timelineItemState)
} }
messagesTimelineItemState
} }
private suspend fun getOrCreateFromCache(timelineItems: List<MatrixTimelineItem>): List<MessagesTimelineItemState> {
val messagesTimelineItemState = ArrayList<MessagesTimelineItemState>()
for (index in timelineItemCaches.indices.reversed()) {
val cacheItem = timelineItemCaches[index]
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
messagesTimelineItemState.add(timelineItemState)
}
} else {
messagesTimelineItemState.add(cacheItem)
}
}
return messagesTimelineItemState
}
private fun calculateAndApplyDiff(timelineItems: List<MatrixTimelineItem>) {
val timeToDiff = measureTimeMillis {
val diffCallback =
MatrixTimelineItemsDiffCallback(
oldList = currentSnapshot,
newList = timelineItems
)
val diffResult = DiffUtil.calculateDiff(diffCallback, false)
currentSnapshot = timelineItems
diffResult.dispatchUpdatesTo(cacheInvalidator)
}
Timber.v("Time to apply diff on new list of ${timelineItems.size} items: $timeToDiff ms")
}
private suspend fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int
): MessagesTimelineItemState? {
val timelineItemState =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> {
buildMessageEvent(
currentTimelineItem,
index,
timelineItems,
)
}
is MatrixTimelineItem.Virtual -> MessagesTimelineItemState.Virtual(
"virtual_item_$index"
)
MatrixTimelineItem.Other -> null
}
timelineItemCaches[index] = timelineItemState
return timelineItemState
}
private suspend fun buildMessageEvent( private suspend fun buildMessageEvent(
currentTimelineItem: MatrixTimelineItem.Event, currentTimelineItem: MatrixTimelineItem.Event,
index: Int, index: Int,
@ -62,12 +113,8 @@ class MessageTimelineItemStateFactory(
val senderAvatarUrl = room.userAvatarUrl(currentSender).getOrNull() val senderAvatarUrl = room.userAvatarUrl(currentSender).getOrNull()
val senderAvatarData = val senderAvatarData =
loadAvatarData(senderDisplayName ?: currentSender, senderAvatarUrl) loadAvatarData(senderDisplayName ?: currentSender, senderAvatarUrl)
val uniqueId = when (val eventKey = currentTimelineItem.event.key()) {
is TimelineKey.TransactionId -> eventKey.txnId
is TimelineKey.EventId -> eventKey.eventId
}
return MessagesTimelineItemState.MessageEvent( return MessagesTimelineItemState.MessageEvent(
id = uniqueId, id = currentTimelineItem.uniqueId,
senderId = currentSender, senderId = currentSender,
senderDisplayName = senderDisplayName, senderDisplayName = senderDisplayName,
senderAvatar = senderAvatarData, senderAvatar = senderAvatarData,
@ -166,6 +213,4 @@ class MessageTimelineItemStateFactory(
.resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value)) .resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value))
return AvatarData(name, model, size) return AvatarData(name, model, size)
} }
}
}

View file

@ -0,0 +1,38 @@
package io.element.android.x.features.messages.diff
import androidx.recyclerview.widget.ListUpdateCallback
import io.element.android.x.features.messages.model.MessagesTimelineItemState
import timber.log.Timber
internal class CacheInvalidator(private val timelineItemCache: MutableList<MessagesTimelineItemState?>) :
ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) {
Timber.v("onChanged(position= $position, count= $count")
(position until position + count).forEach {
// Invalidate cache
timelineItemCache[it] = null
}
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
Timber.v("onMoved(fromPosition= $fromPosition, toPosition= $toPosition")
val model = timelineItemCache.removeAt(fromPosition)
timelineItemCache.add(toPosition, model)
}
override fun onInserted(position: Int, count: Int) {
Timber.v("onInserted(position= $position, count= $count")
repeat(count) {
timelineItemCache.add(position, null)
}
}
override fun onRemoved(position: Int, count: Int) {
Timber.v("onRemoved(position= $position, count= $count")
repeat(count) {
timelineItemCache.removeAt(position)
}
}
}

View file

@ -0,0 +1,35 @@
package io.element.android.x.features.messages.diff
import androidx.recyclerview.widget.DiffUtil
import io.element.android.x.matrix.timeline.MatrixTimelineItem
internal class MatrixTimelineItemsDiffCallback(
private val oldList: List<MatrixTimelineItem>,
private val newList: List<MatrixTimelineItem>
) : DiffUtil.Callback() {
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.getOrNull(oldItemPosition)
val newItem = newList.getOrNull(newItemPosition)
return if (oldItem is MatrixTimelineItem.Event && newItem is MatrixTimelineItem.Event) {
oldItem.uniqueId == newItem.uniqueId
} else {
false
}
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldList.getOrNull(oldItemPosition)
val newItem = newList.getOrNull(newItemPosition)
return oldItem == newItem
}
}

View file

@ -10,6 +10,7 @@ material = "1.6.1"
corektx = "1.9.0" corektx = "1.9.0"
datastore = "1.0.0" datastore = "1.0.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
recyclerview = "1.2.1"
# Compose # Compose
compose_compiler = "1.3.2" compose_compiler = "1.3.2"
@ -52,6 +53,7 @@ androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "corektx"
androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx_constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
androidx_recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" } androidx_compose_bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose_bom" }
androidx_compose_foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx_compose_foundation = { group = "androidx.compose.foundation", name = "foundation" }

View file

@ -2,9 +2,17 @@ package io.element.android.x.matrix.timeline
import org.matrix.rustcomponents.sdk.EventTimelineItem import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.TimelineItem import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineKey
sealed interface MatrixTimelineItem { sealed interface MatrixTimelineItem {
data class Event(val event: EventTimelineItem) : MatrixTimelineItem data class Event(val event: EventTimelineItem) : MatrixTimelineItem {
val uniqueId: String
get() = when (val eventKey = event.key()) {
is TimelineKey.TransactionId -> eventKey.txnId
is TimelineKey.EventId -> eventKey.eventId
}
}
object Virtual : MatrixTimelineItem object Virtual : MatrixTimelineItem
object Other : MatrixTimelineItem object Other : MatrixTimelineItem
} }