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
- Automatic synchronization with server (WebSocket-first with HTTP fallback)
- Soft delete support (items can be recovered on server)
- Optimized large table scrolling (stable row ordering + debounced updates)
## Architecture
@@ -92,6 +93,15 @@ app/src/main/java/com/collabtable/app/
2. Fill in values for each field
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
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
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>>
@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>>
@Query("SELECT * FROM items WHERE id = :itemId")
@@ -38,7 +38,7 @@ val migration2To3 =
Item::class,
ItemValue::class,
],
version = 3,
version = 4,
exportSchema = false,
)
abstract class CollabTableDatabase : RoomDatabase() {
@@ -62,7 +62,7 @@ abstract class CollabTableDatabase : RoomDatabase() {
context.applicationContext,
CollabTableDatabase::class.java,
"collab_table_database",
).addMigrations(migration1To2, migration2To3)
).addMigrations(migration1To2, migration2To3, migration3To4)
.build()
dbInstance = 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,
),
],
indices = [Index("listId")],
indices = [
Index("listId"),
Index(value = ["listId", "createdAt"]),
],
)
data class Item(
@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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.window.Dialog
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.data.database.CollabTableDatabase
import com.collabtable.app.data.model.Field
@@ -956,19 +959,19 @@ fun ItemRow(
remember(itemWithValues.values) {
itemWithValues.values.associateBy { it.fieldId }
}
Row(
val widths = remember(fields, fieldWidths) { fields.map { fieldWidths[it.id] ?: 150.dp } }
EqualHeightRow(
widths = widths,
modifier =
Modifier
.fillMaxWidth()
.height(androidx.compose.foundation.layout.IntrinsicSize.Min)
.clickable(onClick = onClick)
,
verticalAlignment = Alignment.CenterVertically,
.heightIn(min = 48.dp)
.clickable(onClick = onClick),
) {
fields.forEach { field ->
key(field.id) {
val value = valuesByFieldId[field.id]
val fieldWidth = fieldWidths[field.id] ?: 150.dp
val alignment = when (fieldAlignments[field.id]?.lowercase()) {
"center" -> Alignment.Center
"end", "right" -> Alignment.CenterEnd
@@ -983,8 +986,6 @@ fun ItemRow(
Box(
modifier =
Modifier
.width(fieldWidth)
.fillMaxHeight()
.border(
width = 1.dp,
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)
@Composable
fun AddFieldDialog(
@@ -51,9 +51,12 @@ class ListDetailViewModel(
}
viewModelScope.launch {
database.itemDao().getItemsWithValuesForList(listId).collect { itemsData ->
_items.value = itemsData
}
database.itemDao()
.getItemsWithValuesForList(listId)
.debounce(75)
.collect { itemsData ->
_items.value = itemsData
}
}
viewModelScope.launch {