Как мне создать поток из базы данных Room, а также предварительно обработать значения?

Я новичок в разработке Android.

У меня есть база данных результатов Room:

@Entity(tableName = "results")
data class Result(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    val result : Float,
    val date : String
)

@Database(
    entities = [Result::class],
    version = 1,
    exportSchema = false
)
abstract class ResultDatabase : RoomDatabase() {
    abstract fun resultDao() : ResultDao

    companion object {
        @Volatile
        private var Instance : ResultDatabase? = null

        fun getDatabase(context : Context) : ResultDatabase {
            return Instance ?: synchronized(this) {
                Room.databaseBuilder(context, ResultDatabase::class.java, "result_database")
                    .fallbackToDestructiveMigration()
                    .build()
                    .also { Instance = it }
            }
        }
    }
}

С ДАО

@Dao
interface ResultDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(result : Result)

    @Update
    suspend fun update(result : Result)

    @Delete
    suspend fun delete(result : Result)

    @Query("SELECT * FROM results WHERE id = :id")
    fun getResult(id : Int) : Flow<Result>

    @Query("SELECT * FROM results ORDER BY date ASC")
    fun getAllResults() : Flow<List<Result>>
}

и репозиторий

class ResultRepository(private val resultDao : ResultDao) {

    fun getAllResults(): Flow<List<Result>> = resultDao.getAllResults()

    suspend fun getResultsSmoothed(numSteps : Int) : List<Result> {

        var allResults : List<Result> = listOf()
        getAllResults().collect { result -> allResults = allResults + result }

        val minDate = LocalDateTime.parse(allResults.first().date)
        val maxDate = LocalDateTime.parse(allResults.last().date)

        val period = Duration.between(minDate, maxDate)

        var step = period.seconds / numSteps

        var hundredResults = listOf<Result>()

        var nextDate = minDate

        for (i in 0..numSteps) {

            val resultPrior = allResults.fold(allResults.first()) { acc: Result, r ->
                val rd = LocalDateTime.parse(r.date)
                if (rd <= nextDate && (rd > LocalDateTime.parse(acc.date))) r
                else acc
            }

            val resultAfter = allResults.fold(allResults.last()) { acc: Result, r ->
                val rd = LocalDateTime.parse(r.date)
                if (rd > nextDate && (rd <= LocalDateTime.parse(acc.date))) r
                else acc
            }

            if (resultAfter == resultPrior) {
                val newResult = Result(date = nextDate.toString(), result = resultPrior.result)
                hundredResults = hundredResults + newResult
            } else {

                val resultPriorDate = LocalDateTime.parse(resultPrior.date)
                val resultAfterDate = LocalDateTime.parse(resultAfter.date)
                val periodBetween = Duration.between(resultPriorDate, resultAfterDate)

                val periodOver = Duration.between(resultPriorDate, nextDate)

                val percentageOver =
                    periodOver.seconds.toDouble() / periodBetween.seconds.toDouble();

                val resultDifference = resultAfter.result - resultPrior.result;
                val increaseToPriorAmount = resultDifference.toDouble() * percentageOver;

                val stepResult = resultPrior.result + increaseToPriorAmount;

                val newResult = Result(date = nextDate.toString(), result = stepResult.toFloat())
                hundredResults = hundredResults + newResult
            }
            nextDate = nextDate.plusSeconds(step)
        }

        return hundredResults
    }

    suspend fun getResultLabels(numLabels : Int) : List<String> {

        var allResults : List<Result> = listOf()
        getAllResults().collect { result -> allResults = allResults + result }

        val minDate = LocalDateTime.parse(allResults.first().date)
        val maxDate = LocalDateTime.parse(allResults.last().date)

        val period = Duration.between(minDate, maxDate)

        val step = period.seconds / numLabels

        var dateLabels = listOf<String>()

        var nextDate = minDate

        for (i in 0..numLabels) {
            val formatter = DateTimeFormatter.ofPattern("d MMM yy")
            dateLabels = dateLabels + nextDate.format(formatter)
            nextDate = nextDate.plusSeconds(step)
        }

        return dateLabels
    }

    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insertResult(result : Result) {
        resultDao.insert(result)
    }

    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun updateResult(result : Result) {
        resultDao.update(result)
    }

    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun deleteResult(result : Result) {
        resultDao.delete(result)
    }
}

Две функции getResultLabels и getResultsSmoothed обрабатывают некоторые значения из базы данных и возвращают данные. Причина, по которой я поместил это здесь, заключалась в том, чтобы предоставить это модели представления, поскольку я понял, что мне не следует выполнять эту обработку в своих составных объектах.

Тогда моя ViewModel

class ResultViewModel(private val repository: ResultRepository) : ViewModel() {

    val allResults = repository.getAllResults()

    fun getSmoothedResults(num : Int) : Flow<List<Result>> {
        return flow {
            val results = repository.getResultsSmoothed(num)
            Log.d("ME vm", results.toString())
            emit(results)
        }
    }

    fun getResultLabels(num : Int) : Flow<List<String>> {
        return flow { emit(repository.getResultLabels(num)) }
    }

    fun update(result : Result) = viewModelScope.launch {
        repository.updateResult(result)
    }

    fun delete(result : Result) = viewModelScope.launch {
        repository.deleteResult(result)
    }

    fun insert(result : Result) = viewModelScope.launch {
        repository.insertResult(result)
    }
}

class ResultViewModelFactory(private val repository: ResultRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass : Class<T>) : T {
        if (modelClass.isAssignableFrom(ResultViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return ResultViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Я пытаюсь поместить эти List в Flow, чтобы компоновочные элементы, которые их используют, автоматически обновлялись.

Например:

@Composable
fun Metrics(resultViewModel: ResultViewModel,
            resultList : List<Result>?) {

    if (resultList.isNullOrEmpty())
        return

    var dateLabels = resultViewModel.getResultLabels(5).collectAsState(initial = listOf())

    var hundredResults = resultViewModel.getSmoothedResults(100).collectAsState(
        initial = listOf()
    )

    Log.d("memememe", hundredResults.value.toString())

    Column()
    {
        LineChart(
            modifier = Modifier
                .fillMaxWidth()
                .height(300.dp)
                .padding(top = 12.dp, end = 12.dp),
            linesChartData = listOf(LineChartData(
                lineDrawer = SolidLineDrawer(color = MaterialTheme.colorScheme.onBackground),
                points = hundredResults.value.map {
                    LineChartData.Point(it.result, it.id.toString())
                })),
            animation = simpleChartAnimation(),
            pointDrawer = com.github.tehras.charts.line.renderer.point.NoPointDrawer,
            labels = dateLabels.value,
            xAxisDrawer = SimpleXAxisDrawer(
                axisLineThickness = 1.dp,
                axisLineColor = MaterialTheme.colorScheme.onBackground,
                labelTextColor = MaterialTheme.colorScheme.onBackground
            ),
            yAxisDrawer = SimpleYAxisDrawer(
                axisLineThickness = 1.dp,
                axisLineColor = MaterialTheme.colorScheme.onBackground,
                labelTextColor = MaterialTheme.colorScheme.onBackground
            )
        )
        DataTable(
            modifier = Modifier.fillMaxWidth(),
            columns = listOf(
                DataColumn(
                    width = TableColumnWidth.Fixed(60.dp)
                ) {
                    Text("")
                },
                DataColumn {
                    Text("Date")
                },
                DataColumn {
                    Text("Result")
                }
            )
        ) {
            resultList.forEach { r ->
                row {
                    cell {
                        IconButton(onClick = { resultViewModel.delete(r) }) {
                            Icon(Icons.TwoTone.Delete, "Delete result")
                        }
                    }
                    cell {
                        Text(
                            text = LocalDateTime.parse(r.date)
                                .format(DateTimeFormatter.ofPattern("dd MMM yyyy"))
                        )
                    }
                    cell {
                        Text("${r.result}")
                    }
                }
            }
        }
    }
}

Однако мой вызов журнала имеет пустой список. Возможно, я совершенно неправильно подхожу к этой проблеме, но я не уверен. Меня немного смущает использование сопрограмм и состояний, а также то, как они взаимодействуют.

1
0
85
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Вы правы, обработка, которую выполняют getResultLabels и getResultsSmoothed, не относится к составным объектам. Однако ему тоже не место в репозитории.

Этот вид обработки называется бизнес-логикой и должен выполняться между уровнем данных, к которому принадлежит ваш репозиторий, и презентацией пользовательского интерфейса, являющейся вашим кодом Compose. Для этого даже существует специальный уровень — Уровень домена, хотя я бы рекомендовал использовать его только в том случае, если бизнес-логика намного шире, чем только эти две функции. Без выделенного уровня предметной области бизнес-логика выполнялась бы в модели представления. Модель представления технически относится к слою пользовательского интерфейса, но поскольку это место, где необработанные данные преобразуются в состояние пользовательского интерфейса, она вполне подходит сюда.

Однако это не решит вашу проблему.

В чистой архитектуре модель представления в идеале передает состояние компонуемым объектам только путем предоставления свойств типа StateFlow, одновременно получая события из пользовательского интерфейса с помощью функций без возвращаемого значения (последнее уже хорошо смотрится в вашем коде). Более того, общая идея состоит в том, чтобы ваши источники данных (в вашем случае база данных Room) возвращали поток, который будет трансформироваться только на пути вверх по слоям, пока он не будет собран в пользовательском интерфейсе. Однако в вашем коде поток базы данных уже собирается репозиторием, и возвращаются простые списки. Затем в вашей модели представления вы снова пытаетесь обернуть списки в потоки, что не сработает, поскольку потоки модели представления теперь отделены от базы данных.

Цель состоит в том, чтобы пользовательский интерфейс получал поток, который автоматически обновлялся текущими значениями в базе данных, когда там что-то меняется, без необходимости опроса новых результатов путем повторного вызова некоторых функций. Для этого потоки следует собирать только в композиции. Ни в репозитории, ни в модели представления.

Чтобы выполнить рефакторинг вашего кода в соответствии с этим, вам сначала необходимо извлечь бизнес-логику из репозитория. На данный момент функции getResultsSmoothed и getResultLabels работают с потоками. Однако бизнес-логика не должна иметь ничего общего с потоками. Он должен просто преобразовать данные, в данном случае список результатов, в список чего-то еще. Лучше всего перенести преобразования в отдельный файл как функции верхнего уровня на уровне файла:

fun getResultsSmoothed(
    allResults: List<Result>,
    numSteps: Int,
): List<Result> {
    val minDate = LocalDateTime.parse(allResults.first().date)
    val maxDate = LocalDateTime.parse(allResults.last().date)

    val period = Duration.between(minDate, maxDate)

    val step = period.seconds / numSteps

    var hundredResults = listOf<Result>()

    var nextDate = minDate

    for (i in 0..numSteps) {
        val resultPrior = allResults.fold(allResults.first()) { acc: Result, r ->
            val rd = LocalDateTime.parse(r.date)
            if (rd <= nextDate && (rd > LocalDateTime.parse(acc.date))) r
            else acc
        }

        val resultAfter = allResults.fold(allResults.last()) { acc: Result, r ->
            val rd = LocalDateTime.parse(r.date)
            if (rd > nextDate && (rd <= LocalDateTime.parse(acc.date))) r
            else acc
        }

        if (resultAfter == resultPrior) {
            val newResult = Result(date = nextDate.toString(), result = resultPrior.result)
            hundredResults = hundredResults + newResult
        } else {
            val resultPriorDate = LocalDateTime.parse(resultPrior.date)
            val resultAfterDate = LocalDateTime.parse(resultAfter.date)
            val periodBetween = Duration.between(resultPriorDate, resultAfterDate)

            val periodOver = Duration.between(resultPriorDate, nextDate)

            val percentageOver =
                periodOver.seconds.toDouble() / periodBetween.seconds.toDouble()

            val resultDifference = resultAfter.result - resultPrior.result
            val increaseToPriorAmount = resultDifference.toDouble() * percentageOver

            val stepResult = resultPrior.result + increaseToPriorAmount

            val newResult = Result(date = nextDate.toString(), result = stepResult.toFloat())
            hundredResults = hundredResults + newResult
        }
        nextDate = nextDate.plusSeconds(step)
    }

    return hundredResults
}

fun getResultLabels(
    allResults: List<Result>,
    numLabels: Int,
): List<String> {
    val minDate = LocalDateTime.parse(allResults.first().date)
    val maxDate = LocalDateTime.parse(allResults.last().date)

    val period = Duration.between(minDate, maxDate)

    val step = period.seconds / numLabels

    var dateLabels = listOf<String>()

    var nextDate = minDate

    for (i in 0..numLabels) {
        val formatter = DateTimeFormatter.ofPattern("d MMM yy")
        dateLabels = dateLabels + nextDate.format(formatter)
        nextDate = nextDate.plusSeconds(step)
    }

    return dateLabels
}

Теперь они независимы от остального кода и могут вызываться всякий раз, когда необходимо преобразовать List<Result>, независимо от текущего контекста.

Обе функции в репозитории теперь можно удалить, и репозиторий теперь очищен от любых коллекций потоков, как и должно быть. Единственные данные, которые он предоставляет, — это поток всех результатов, возвращаемых функцией getAllResults.

После этого модели представления теперь просто нужно принять этот поток и преобразовать только его содержимое, используя две функции, которые мы только что освободили из репозитория:

val smoothedResults: StateFlow<List<Result>> = repository.getAllResults()
    .mapLatest { results ->
        getResultsSmoothed(results, 100)
    }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = emptyList(),
    )

val resultLabels = repository.getAllResults()
    .mapLatest { results ->
        getResultLabels(results, 5)
    }
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = emptyList(),
    ) 

mapLatest работает с потоками очень похоже на то, как map работает со списками: для каждой новой эмиссии потока содержимое потока заменяется результатом лямбды. Возвращаемым значением по-прежнему является Flow, изменилось только содержимое, и нам не нужно было ничего собирать.

После этого поток преобразуется в StateFlow с помощью stateIn. StateFlow — это специально настроенный поток, который не сохраняет историю и предоставляет своим сборщикам только самое актуальное значение. Более того, это горячий поток, а значит, он работает сам по себе и, следовательно, может использоваться несколькими коллекторами. Именно поэтому нам нужно предоставить viewModelScope, чтобы он мог внутренне запустить сопрограмму, чтобы сделать все это. А поскольку он работает сам по себе, нам также необходимо предоставить начальное значение, которое будет использоваться до тех пор, пока восходящий поток не выдаст свое первое значение. В документации Android есть хорошее введение в StateFlows (немного устарело, там все еще собираются потоки в модели представления), но поскольку потоки являются частью Kotlin, для полного обзора вам следует взглянуть на Документация Kotlin.

Как видно из приведенного выше кода, smoothedResults и resultLabels теперь являются свойствами. Поскольку у них нет параметров, я переместил 100 и 5, которые ранее использовались для вызова функций getSmoothedResults и getResultLabels, в саму модель представления. Функции больше не нужны, и теперь их можно удалить.

Теперь все должно работать так, как ожидалось. Вместо этого вашему коду Compose нужно просто собирать потоки из новых свойств:

var dateLabels = resultViewModel.resultLabels.collectAsState()
var hundredResults = resultViewModel.smoothedResults.collectAsState()

Я удалил здесь начальное значение, поскольку оно уже является частью StateFlow в модели представления. На самом деле вам следует заменить collectAsState на collectAsStateWithLifecycle из зависимости Gradle androidx.lifecycle:lifecycle-runtime-compose. Это будет немного более эффективно, поскольку сбор потока останавливается, когда компонуемый объект не отображается на экране, с учетом жизненного цикла действия. Кроме того, переменные можно объявить как val вместо var, поскольку они содержат поток. Значения потока могут меняться, но не сам поток. А чтобы сделать его идеальным, вы можете использовать Kotlin с помощью делегатов для развертывания свойства значения состояния. Тогда это будет выглядеть так:

val dateLabels by resultViewModel.resultLabels.collectAsStateWithLifecycle()
val hundredResults by resultViewModel.smoothedResults.collectAsStateWithLifecycle()

Где бы вы ни использовали hundredResults.value и dateLabels.value раньше, теперь вы можете просто использовать hundredResults и dateLabels соответственно.


Осталось решить всего две проблемы:

  1. Как вы, возможно, уже поняли, вы больше не можете параметризовать getResultLabels(5) и getSmoothedResults(100) из пользовательского интерфейса. Значения теперь жестко запрограммированы в модели представления. Чтобы изменить это, значения должны сами исходить из потока. На первый взгляд это звучит сложно, но с помощью Kotlin это можно сделать довольно элегантно. На примере smoothedResults это будет выглядеть так:

    private val smoothedResultsNum = MutableStateFlow(100)
    
    val smoothedResults: StateFlow<List<Result>> = repository.getAllResults()
        .combine(smoothedResultsNum) { results, num ->
            getResultsSmoothed(results, num)
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = emptyList(),
        )
    
    fun updateSmoothedResultsNum(num: Int) {
        smoothedResultsNum.value = num
    }
    

    MutableStateFlow — это просто контейнер для значения, к которому вы можете получить доступ с помощью свойства value. Это также полноценный поток, и поэтому его можно комбинировать с другими потоками, как показано выше. Он начинается со значения 100, но вы можете изменить это значение в любое время, вызвав updateSmoothedResultsNum из своих составных объектов. smoothedResults StateFlow всегда будет обновляться, когда какой-либо из базовых потоков получит новое значение. То есть либо база данных содержит новые значения, либо число было изменено.

    То же самое можно применить и к другому StateFlow, resultLabels.

  2. Это то, что уже было проблемой в исходном коде. Функция вашего репозитория getAllResults() вызывается несколько раз (точнее, дважды). Каждый раз, когда он вызывается, к базе данных выполняется новый SQL-запрос и возвращается новый поток. Однако с тем же содержанием, так что в этом нет необходимости. Если в базе данных мало данных, это не имеет большого значения, но с увеличением объема данных или дополнительными вызовами функции это может довольно быстро стать узким местом.

    Решение состоит в том, чтобы преобразовать поток в SharedFlow в репозитории. Это общая версия StateFlow. Это также горячий поток, который необходим для независимого запуска базового потока и предоставления каждому сборщику, подписанному на SharedFlow, одинаковые значения. Отсюда и название: этот поток, в отличие от холодных потоков, возвращаемых из помещения f.e., может использоваться несколькими коллекторами. Это также означает, что вам нужна область действия сопрограммы, как вы это сделали с StateFlow в модели представления:

    class ResultRepository(
        private val resultDao: ResultDao,
        scope: CoroutineScope,
    ) {
        val allResults: Flow<List<Result>> = resultDao.getAllResults()
            .shareIn(
                scope = scope,
                started = SharingStarted.WhileSubscribed(),
            )
    
        // ...
    }
    

    За исключением модели представления, ваш репозиторий не имеет встроенной области сопрограммы, поэтому ее необходимо указать в качестве параметра для конструктора. Когда вы создаете свой репозиторий в действии, вы можете использовать для этого действие lifecycleScope.

Большое спасибо. Google должен нанять вас для написания своих руководств. Это объясняет так много всего, чего я раньше не мог понять. Спасибо.

NeomerArcana 05.05.2024 22:48

Да, все это довольно ново, и лучшие практики находятся в стадии развития. Официальная документация часто не соответствует действительности. У меня сложилось впечатление, что некоторые примеры приложений от Google лучше демонстрируют, как следует использовать некоторые продукты, которые они производят.

Leviathan 05.05.2024 23:40

Проект Sunflower может быть интересен более простыми частями, тогда как проект Now in Android является более продвинутым. Оба они активно поддерживаются. Просто проверьте их с помощью Android Studio (Файл -> Создать -> Проект из контроля версий..., затем вставьте URL-адрес; если у вас установлен Git, в противном случае вам придется все загружать вручную) и повозитесь с ними. Приятного кодирования!

Leviathan 05.05.2024 23:40

Спасибо за подсказку, я их проверю

NeomerArcana 06.05.2024 04:25

Другие вопросы по теме