Я новичок в разработке 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}")
}
}
}
}
}
}
Однако мой вызов журнала имеет пустой список. Возможно, я совершенно неправильно подхожу к этой проблеме, но я не уверен. Меня немного смущает использование сопрограмм и состояний, а также то, как они взаимодействуют.
Вы правы, обработка, которую выполняют 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
соответственно.
Осталось решить всего две проблемы:
Как вы, возможно, уже поняли, вы больше не можете параметризовать 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
.
Это то, что уже было проблемой в исходном коде. Функция вашего репозитория 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 лучше демонстрируют, как следует использовать некоторые продукты, которые они производят.
Проект Sunflower может быть интересен более простыми частями, тогда как проект Now in Android является более продвинутым. Оба они активно поддерживаются. Просто проверьте их с помощью Android Studio (Файл -> Создать -> Проект из контроля версий..., затем вставьте URL-адрес; если у вас установлен Git, в противном случае вам придется все загружать вручную) и повозитесь с ними. Приятного кодирования!
Спасибо за подсказку, я их проверю
Большое спасибо. Google должен нанять вас для написания своих руководств. Это объясняет так много всего, чего я раньше не мог понять. Спасибо.