feat: optimize large table performance with stable row ordering and debounced updates

This commit is contained in:
2025-11-11 23:09:37 +01:00
parent d5bdc3e75d
commit 7e4896fc69
6 changed files with 91 additions and 16 deletions
+10
View File
@@ -11,6 +11,7 @@ A collaborative table management Android application built with Jetpack Compose
- Local Room database for offline support - Local Room database for offline support
- Automatic synchronization with server (WebSocket-first with HTTP fallback) - Automatic synchronization with server (WebSocket-first with HTTP fallback)
- Soft delete support (items can be recovered on server) - Soft delete support (items can be recovered on server)
- Optimized large table scrolling (stable row ordering + debounced updates)
## Architecture ## Architecture
@@ -92,6 +93,15 @@ app/src/main/java/com/collabtable/app/
2. Fill in values for each field 2. Fill in values for each field
3. Values are saved automatically as you type 3. Values are saved automatically as you type
### Performance Notes (Large Tables)
For very large tables the app applies several optimizations:
- Rows are kept in their original creation order rather than sorting by last update timestamp. This prevents the entire list from resorting after each edit and reduces scroll jank.
- Rapid successive database emissions are coalesced (debounced ~75ms) before updating the UI state, lowering unnecessary recompositions during bulk edits or sync bursts.
Further planned improvements:
- Incremental paging for extremely large datasets
- Horizontal virtualization for very wide column sets
If you encounter sluggishness with tens of thousands of rows, enable server-side filtering/sorting to reduce client load.
### Column Content Alignment ### Column Content Alignment
In the Edit Column dialog you can choose how each column's cell content is aligned (Left, Center, Right) via a single connected Material 3 segmented button group. The selection persists per list/field and updates immediately when you save changes. To adjust later, open the Manage Columns dialog or edit the field again. In the Edit Column dialog you can choose how each column's cell content is aligned (Left, Center, Right) via a single connected Material 3 segmented button group. The selection persists per list/field and updates immediately when you save changes. To adjust later, open the Manage Columns dialog or edit the field again.
@@ -11,11 +11,14 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface ItemDao { interface ItemDao {
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") // Use stable creation order instead of updatedAt-based ordering to avoid resorting the entire list
// on every cell edit (which bumps updatedAt). This dramatically reduces scroll jank and recomposition
// churn for large tables while still presenting rows in a predictable order.
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY createdAt ASC")
fun getItemsForList(listId: String): Flow<List<Item>> fun getItemsForList(listId: String): Flow<List<Item>>
@Transaction @Transaction
@Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY updatedAt DESC") @Query("SELECT * FROM items WHERE listId = :listId AND isDeleted = 0 ORDER BY createdAt ASC")
fun getItemsWithValuesForList(listId: String): Flow<List<ItemWithValues>> fun getItemsWithValuesForList(listId: String): Flow<List<ItemWithValues>>
@Query("SELECT * FROM items WHERE id = :itemId") @Query("SELECT * FROM items WHERE id = :itemId")
@@ -38,7 +38,7 @@ val migration2To3 =
Item::class, Item::class,
ItemValue::class, ItemValue::class,
], ],
version = 3, version = 4,
exportSchema = false, exportSchema = false,
) )
abstract class CollabTableDatabase : RoomDatabase() { abstract class CollabTableDatabase : RoomDatabase() {
@@ -62,7 +62,7 @@ abstract class CollabTableDatabase : RoomDatabase() {
context.applicationContext, context.applicationContext,
CollabTableDatabase::class.java, CollabTableDatabase::class.java,
"collab_table_database", "collab_table_database",
).addMigrations(migration1To2, migration2To3) ).addMigrations(migration1To2, migration2To3, migration3To4)
.build() .build()
dbInstance = instance dbInstance = instance
instance instance
@@ -77,3 +77,11 @@ abstract class CollabTableDatabase : RoomDatabase() {
} }
} }
} }
// Add index to accelerate item listing by listId ordered by creation time
val migration3To4 =
object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE INDEX IF NOT EXISTS index_items_listId_createdAt ON items(listId, createdAt)")
}
}
@@ -15,7 +15,10 @@ import androidx.room.PrimaryKey
onDelete = ForeignKey.CASCADE, onDelete = ForeignKey.CASCADE,
), ),
], ],
indices = [Index("listId")], indices = [
Index("listId"),
Index(value = ["listId", "createdAt"]),
],
) )
data class Item( data class Item(
@PrimaryKey val id: String, @PrimaryKey val id: String,
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -81,6 +82,8 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.unit.Constraints
import com.collabtable.app.R import com.collabtable.app.R
import com.collabtable.app.data.database.CollabTableDatabase import com.collabtable.app.data.database.CollabTableDatabase
import com.collabtable.app.data.model.Field import com.collabtable.app.data.model.Field
@@ -956,19 +959,19 @@ fun ItemRow(
remember(itemWithValues.values) { remember(itemWithValues.values) {
itemWithValues.values.associateBy { it.fieldId } itemWithValues.values.associateBy { it.fieldId }
} }
Row( val widths = remember(fields, fieldWidths) { fields.map { fieldWidths[it.id] ?: 150.dp } }
EqualHeightRow(
widths = widths,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(androidx.compose.foundation.layout.IntrinsicSize.Min) .heightIn(min = 48.dp)
.clickable(onClick = onClick) .clickable(onClick = onClick),
,
verticalAlignment = Alignment.CenterVertically,
) { ) {
fields.forEach { field -> fields.forEach { field ->
key(field.id) { key(field.id) {
val value = valuesByFieldId[field.id] val value = valuesByFieldId[field.id]
val fieldWidth = fieldWidths[field.id] ?: 150.dp
val alignment = when (fieldAlignments[field.id]?.lowercase()) { val alignment = when (fieldAlignments[field.id]?.lowercase()) {
"center" -> Alignment.Center "center" -> Alignment.Center
"end", "right" -> Alignment.CenterEnd "end", "right" -> Alignment.CenterEnd
@@ -983,8 +986,6 @@ fun ItemRow(
Box( Box(
modifier = modifier =
Modifier Modifier
.width(fieldWidth)
.fillMaxHeight()
.border( .border(
width = 1.dp, width = 1.dp,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
@@ -1453,6 +1454,53 @@ fun ItemRow(
} }
} }
@Composable
private fun EqualHeightRow(
widths: List<Dp>,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val density = LocalDensity.current
val widthsPx = remember(widths, density) { widths.map { with(density) { it.roundToPx() } } }
Layout(
modifier = modifier,
content = content,
) { measurables, outerConstraints ->
val count = measurables.size
val n = widthsPx.size.coerceAtMost(count)
// First pass: measure children with fixed widths and unconstrained height to find max height
var maxHeight = 0
val placeablesFirst = Array(n) { i ->
val w = widthsPx[i].coerceAtLeast(0)
val c = Constraints.fixedWidth(w)
val p = measurables[i].measure(c)
if (p.height > maxHeight) maxHeight = p.height
p
}
// Second pass: remeasure with fixed width and fixed row height so cells share same height
val placeables = Array(n) { i ->
val w = widthsPx[i].coerceAtLeast(0)
val c = Constraints.fixed(width = w, height = maxHeight)
measurables[i].measure(c)
}
val totalWidth = widthsPx.take(n).sum()
val layoutWidth = totalWidth.coerceIn(outerConstraints.minWidth, outerConstraints.maxWidth)
val layoutHeight = maxHeight.coerceIn(outerConstraints.minHeight, outerConstraints.maxHeight)
layout(layoutWidth, layoutHeight) {
var x = 0
for (i in 0 until n) {
val p = placeables[i]
p.placeRelative(x, 0)
x += p.width
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddFieldDialog( fun AddFieldDialog(
@@ -51,9 +51,12 @@ class ListDetailViewModel(
} }
viewModelScope.launch { viewModelScope.launch {
database.itemDao().getItemsWithValuesForList(listId).collect { itemsData -> database.itemDao()
_items.value = itemsData .getItemsWithValuesForList(listId)
} .debounce(75)
.collect { itemsData ->
_items.value = itemsData
}
} }
viewModelScope.launch { viewModelScope.launch {