Comments 1
Есть пара замечаний по представленным примерам, глобально отличающимся друг от друга тем, что в первом триггер для запуска запроса (в общем случае какой-то suspend функции - из репозитория или UseCase не так важно) отсутствует, а во втором он есть в виде исходного Flow.
В первом примере говорится, что вызов
getWords()
, запускающий корутину, вызывается при инициализации VM и соот-но является bad practice. Но что мешает сделать то же самое по иницативе View (Fragment/Activity) в нужном месте ЖЦ? Вот эта вещь
val state: StateFlow<UiState> = flow {
emit(UiState(isLoading = true))
val words = wordsUseCase.invoke()
emit(UiState(isLoading = false, words = words))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState())
будет означать невозможность вызова функции из любого места, если я правильно понимаю, т.е. станет одноразовой с запусканием только при появлении подписчика черех 5 секунд? Кстати, не совсем понятно, почему именно update при рефреше StateFlow.
Или на этот случай вижу такой лайфхак на случай, если надо оставить возможность вызова getWords()
из любого места, а не только при создании: поверх того StateFlow, который апдейтим руками, сделать другой StateFlow через stateIn:
val uiState = viewModelState.stateIn(
scope = viewModelScope,
started = WhileViewSubscribed,
initialValue = UiState.Loading
)
Тогда getWords можно оставить в инициализации вместе с возможностью вызова из любого места по требованию - фрагмент получит результат когда подпишется. Вы конечно можете возразить по поводу такого подхода - ведь запрос-то выполнится, ради чего тогда stateIn
? :)
Касаемо того, что выполняется в самой корутине - в общем случае лучше выносить в UseCase по типу такого (был скопипащен откуда-то):
abstract class UseCase<in P, R>(private val coroutineDispatcher: CoroutineDispatcher) {
/** Executes the use case asynchronously and returns a [UseCaseResult].
*
* @return a [UseCaseResult].
*
* @param parameters the input parameters to run the use case with
*/
suspend operator fun invoke(parameters: P): UseCaseResult<R> {
runBlocking {
coroutineContext[Job]
}
return try {
// Moving all use case's executions to the injected dispatcher
// In production code, this is usually the Default dispatcher (background thread)
// In tests, this becomes a TestCoroutineDispatcher
withContext(coroutineDispatcher) {
execute(parameters).let {
UseCaseResult.Success(it)
}
}
} catch (e: Throwable) {
Timber.e(e)
e.asUseCaseResult()
}
}
/**
* Override this to set the code to be executed.
*/
@Throws(RuntimeException::class)
protected abstract suspend fun execute(parameters: P): R
}
Возможно ваш похож на это, но видно, что UI никак не отреагирует, если был еррор, а не успех с пустым результатом. Перед его вызовом апдейтить свой state на Loading, а после в зав-ти от результата.
Во втором примере цепочка активируется только при наличии элементов в исходном Flow, хотя сам по себе запуск корутины кажется безвредным (кроме срабатывания первого collect, т.к. есть initial значение, которое обязательно у MutableStateFlow). В проекте с прошлой работы
FlowUseCase
(отличается от обычного UseCase результатом в видеFlow<UseCaseResult<T>>
) был сделан несколько по-другому - collect идёт над тем flow, который вернули вflatMapLatest
. Мой пример такого search:
class AddressSuggestUseCase @Inject constructor(
private val repository: AddressRepo,
@Dispatcher(AppDispatchers.IO)
ioDispatcher: CoroutineDispatcher,
) : FlowUseCase<Flow<AddressSuggestUseCase.Parameters>, List<AddressSuggest>>(ioDispatcher) {
@OptIn(ExperimentalCoroutinesApi::class)
override fun execute(parameters: Flow<Parameters>): Flow<UseCaseResult<List<AddressSuggest>>> =
parameters
.map { it.copy(query = it.query.trim()) }
.debounce {
if (it.query.length < SUGGEST_THRESHOLD) {
0
} else {
SUGGEST_DELAY
}
}
.distinctUntilChanged()
.flatMapLatest { p ->
flow {
if (p.query.length < SUGGEST_THRESHOLD) {
emit(UseCaseResult.Success(emptyList()))
} else {
emit(UseCaseResult.Loading)
val result = try {
UseCaseResult.Success(repository.suggestWithRefresh(p.id, p.query))
} catch (e: Exception) {
UseCaseResult.Error(e)
}
emit(result)
}
}
}
data class Parameters(
val id: Long,
val query: String,
)
companion object {
private const val SUGGEST_THRESHOLD = 2
private const val SUGGEST_DELAY = 500L
}
}
Т.е. вместо ручного рефреша LD или StateFlow (кстати, нужность этого класса против LD для фрагмента при том, что всегда есть возможность вызвать asLiveData()
- отдельная тема для обсуждения), эмитим в свой flow, на стороне VM собираем.
withContext(ioDispatcher)
кстати в любом случае необходим, только в более правильном месте - в вызове DataSource - таким образом гарантируется выполнение на правильном потоке, не завися от контекста корутины, в которой это происходит.
Общая суть касаемо сбора остаётся такой же: инстанцируем при создании VM один раз UseCase (руками или передаём Hilt'ом), и при этом момент запуска корутины, которая будет собирать результат, недетерминирован: это может быть init (что в данном случае осуждается) или какое-то другое место - например, если поля, по которым будет ввод с подстановками в такой UseCase не один, а они создаются и убираются динамически.
Соот-но, createUiState()
как бы уже лишает такой универсальности (либо надо будет писать новый базовый UseCase, который вместо Flow возвращает LiveData, но это уже будет собсно и не UseCase вовсе). Ну и здесь searchUseCase.get().invoke(query)
по сути просто идёт вызов вызов метода репозитория, т.к. идёт оборачивание в try-catch, а это как раз то, что в том числе должен делать UseCase.
Ещё небольшой комментарий касаемо экранных стейтов в виде sealed-классов. Считаю, что в них нет необходимости, если они не привносят ничего нового по сравнению с общим для всего прожекта (UseCaseResult в данном случае, из которого я по месту маплю более привычный LoadState, с которым удобнее работать фрагменту) + надо также учитывать, что на экране их может быть несколько (потому кстати меня всегда напрягал MVI'ный подход, когда весь экран описывается одним State'ом с кучей полей, по которому рефреш дёргается на любое изменение одного из полей, а Compose'ом я, извиняйте, пока не владею - там где оно действительно не будет перерисовывать целиком на каждое изменение) . Если свой sealed в конкретном месте действительно нужен - апдейтить StateFlow им в зав-ти от UseCaseResult, который был возвращён.
В заключении, могу согласиться, что в первом случае вызов запроса в init, действительно выглядит плохо (и вообще во всех остальных случаях надо взять за правило, что когда возникает необходимость вызова init - делать это в таком onInitialized, преимущественно для целей наследования:
init {
Handler(Looper.getMainLooper()).post { onInitialized() }
}
). А во втором, кмк, никакого криминала что для collect из flow будет запущена корутина как бы заранее - ведь логично, что если есть тот, кто кидает поисковые строки в запрос, тот и заинтересован в получении результата. liveData {}
здесь больше выглядит как дополнительное усложнение.
ViewModels в Android: «за» и «против»