In the Android documentation it says the following:
Dispatchers.IO - This dispatcher is optimized to perform disk or network I/O outside of the main thread. Examples include using the Room component, reading from or writing to files, and running any network operations.
In my application I need in some cases to read .json
files located in the assets
directory, convert those files (using Moshi) and display them on screen using Jetpack Compose.
Based on what is said in the documentation, I understand that I should use Dispatchers.IO for this type of operations.
In the ViewModel, I have this code, which reads a given local file:
@HiltViewModel
class FileViewModel @Inject constructor(
private val getFileUseCase: GetFileUseCase,
@Dispatcher(LPlusDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _uiState = MutableStateFlow<FileUiState>(FileUiState.Loading)
val uiState: StateFlow<FileUiState> = _uiState.asStateFlow()
fun loadData(fileRequest: FileRequest) {
_uiState.value = FileUiState.Loading
viewModelScope.launch(ioDispatcher) {
try {
val result = getFileUseCase(fileRequest)
_uiState.value = FileUiState.Loaded(FileItemUiState(result))
} catch (error: Exception) {
_uiState.value = FileUiState.Error(ExceptionParser.getMessage(error))
}
}
}
//...
}
This is the UseCase:
class GetFileUseCase @Inject constructor(
private val fileRepository: LocalFileRepository
) {
suspend operator fun invoke(fileRequest: FileRequest): MutableList<FileResponse> =
fileRepository.getFile(fileRequest)
}
And this is the code in the repository:
override suspend fun getFile(fileRequest: FileRequest): MutableList<FileResponse> {
val fileResponse = assetProvider.getFiles(fileRequest.fileName)
val moshi = Moshi.Builder()
.add(
PolymorphicJsonAdapterFactory.of(Content::class.java, "type")
.withSubtype(Paragraphus::class.java, "p")
.withSubtype(Rubrica::class.java, "r")
.withSubtype(Titulus::class.java, "t")
//...
)
.add(KotlinJsonAdapterFactory())
.build()
fileResponse.forEach {
if (books.contains(it.fileName)) {
it.text = moshi.adapter(Book::class.java).fromJson(it.text.toString()))
// ...
}
}
return fileResponse
}
What surprises me is when putting viewModelScope.launch(ioDispatcher)
, the code is constantly running. That is, if I put a breakpoint in the code that searches for the file(s) passed in the parameter, it constantly stops at that point. On the other hand, if I put only viewModelScope.launch()
the code works as expected, reading the file(s) passed in the parameter only once.
My question is this: Is it not necessary to use Dispatchers.IO in this case, even though the documentation says to use it for reading files? Why?
I don't know if what changes here is the use of Jetpack Compose. Below I show the use I give to the state generated in the ViewModel:
@Composable
fun FileScreen(
modifier: Modifier = Modifier,
fileRequest: FileRequest,
viewModel: FileViewModel = hiltViewModel(),
)
{
viewModel.loadData(fileRequest)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
FileScreen(modifier = modifier, uiState = uiState)
}
@Composable
fun FileScreen(
modifier: Modifier,
uiState: FileViewModel.FileUiState
)
{
when (uiState) {
FileViewModel.FileUiState.Empty -> EmptyState()
is FileViewModel.FileUiState.Error -> ErrorState()
is FileViewModel.FileUiState.Loaded -> {
uiState.itemState.allData.forEach {
Text(text = it.text)
}
}
FileViewModel.FileUiState.Loading -> LoadingState()
}
}
You have an infinite loop:
uiState
with collectAsStateWithLifecycle()
uiState
in response to the read fileuiState
FileScreen
again due to the recomposition...The issue is not that you used the IO dispatcher, the issue is that you repeat viewModel.loadData(fileRequest)
on every recomposition. A quick fix is to wrap it in a LaunchedEffect:
LaunchedEffect(viewModel, fileRequest) {
viewModel.loadData(fileRequest)
}
This skips recompositions as long as the LaunchedEffect's parameters (i.e. viewModel
, and fileRequest
) stay the same.
A better solution is to refactor your code to never directly call the function from your compose code in the first place:
private val fileRequest = MutableStateFlow<FileRequest?>(null)
val uiState: StateFlow<FileUiState> = fileRequest
.flatMapLatest {
it?.let(::loadDataFlow) ?: flowOf(FileUiState.Empty)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FileUiState.Empty,
)
private fun loadDataFlow(fileRequest: FileRequest): Flow<FileUiState> = flow {
emit(FileUiState.Loading)
try {
val result = getFileUseCase(fileRequest)
emit(FileUiState.Loaded(FileItemUiState(result)))
} catch (error: Exception) {
emit(FileUiState.Error(ExceptionParser.getMessage(error)))
}
}
fun setFileRequest(fileRequest: FileRequest?) {
this.fileRequest.value = fileRequest
}
The only thing your composable needs to do now is to set the fileRequest:
viewModel.setFileRequest(fileRequest)
Although this should probably be wrapped in a LaunchedEffect too for performance reasons, you won't end up with erroneous behavior like an infinite loop if you don't.
uiState
is now a flow that is built on top of another flow holding the requested fileRequest
. The major difference here is that getFileUseCase
isn't explicitly called anymore, instead it is indirectly called during the flow transformation of uiState
(i.e. flatMapLatest
). Even if you call setFileRequest
repeatedly the flow transformation is only executed once. Only when you pass a different fileRequest
the flow transformation is exectued again.
loadDataFlow
is pretty similar to loadData
except that it is private now and returns a flow (and therefore isn't a suspend function anymore).
As you can see the switch to the IO dispatcher is missing now. Although you need the IO dispatcher, it should only be used at the latest possible moment: That is the repository, not the view model.
Independent of which of the above solutions you use, you shold remove the IO dispatcher from the view model and change your repository's getFile
to this (and remove the return
keyword):
override suspend fun getFile(fileRequest: FileRequest): MutableList<FileResponse> =
withContext(ioDispatcher) {
// ...
}
This is needed because assetProvider.getFiles
actually does the dirty work accessing the file system, so it has the responsibility to select the proper dispatcher. Since you cannot change that function the responsibility falls to the caller, that is getFile
. Fortunately you use Hilt so you can easily inject the dispatcher into the repository.
And while we're at it, I don't see a reason why getFile
should return a MutableList. You should use immutable types as much as possible, especially where Compose and StateFlow are involved. Therefore you should change the return type to List<FileResponse>
.
While the above explains how to fix the issue it is still unclear why omitting the IO dispatcher actually worked.
When omitting the IO dispatcher the current dispatcher is used. Since you call the function from a composable, that is the Main dispatcher. The Main dispatcher only has a single thread, the Main thread that the UI runs on. Reading the file on the Main thread will therefore freeze the UI until the file is loaded. That's the reason why you should switch to the IO dispatcher, to free the Main dispatcher so it can still display a reponsive UI.
But freezing the UI was what actually prevented you to enter the infinite loop in the first place. Instead of the infinite loop the following happens when you don't use the IO dispatcher:
Preparing to read the file
This launches a new coroutine, but it still uses the Main dispatcher. With the default settings this still immediately returns but it schedules the coroutine to be executed later.
Preparing to observe uiState
with collectAsStateWithLifecycle()
This internally calls LaunchedEffect
which also launches a new coroutine, also on the Main dispatcher. This also returns immediately and also schedules its execution for later.
Actually reading the file
Since 2. suspended by launching a new coroutine the first coroutine that was queued can now be executed and the file is read.
Actually observing uiState
with collectAsStateWithLifecycle()
When 3. finishes the coroutine that should observe uiState
is now executed.
The first observed uiState
is FileUiState.Loaded
because observation started after the file was read.
No state changes occur so no recompositions are scheduled and you won't be stuck in an infinite loop.
This is very fragile, though. There is no guarantee the queued coroutines are executed in this order, and there can always be another cause triggering a recomposition which will then end up in an infinite loop.
It was only by accident that omitting the IO dispatcher seemed to actually work.