feat: optimize large table performance with stable row ordering and debounced updates
This commit is contained in:
@@ -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")
|
||||
|
||||
+10
-2
@@ -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,
|
||||
|
||||
+56
-8
@@ -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(
|
||||
|
||||
+6
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user