Как стать автором
Обновить

Комментарии 1

Есть пара замечаний по представленным примерам, глобально отличающимся друг от друга тем, что в первом триггер для запуска запроса (в общем случае какой-то suspend функции - из репозитория или UseCase не так важно) отсутствует, а во втором он есть в виде исходного Flow.

  1. В первом примере говорится, что вызов 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, а после в зав-ти от результата.

  1. Во втором примере цепочка активируется только при наличии элементов в исходном 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 {} здесь больше выглядит как дополнительное усложнение.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий