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
|
- 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")
|
||||||
|
|||||||
+10
-2
@@ -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,
|
||||||
|
|||||||
+56
-8
@@ -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(
|
||||||
|
|||||||
+4
-1
@@ -51,7 +51,10 @@ class ListDetailViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
database.itemDao().getItemsWithValuesForList(listId).collect { itemsData ->
|
database.itemDao()
|
||||||
|
.getItemsWithValuesForList(listId)
|
||||||
|
.debounce(75)
|
||||||
|
.collect { itemsData ->
|
||||||
_items.value = itemsData
|
_items.value = itemsData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user