androidkotlinandroid-jetpack-composekotlin-coroutinesandroid-mvvm

How to use Dispatchers.IO correctly to read local files on Android?


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()
    }
}

Solution

  • You have an infinite loop:

    1. Reading the file
    2. Observing uiState with collectAsStateWithLifecycle()
    3. Changing uiState in response to the read file
    4. Triggering a recomposition because of the changed uiState
    5. Executing FileScreen again due to the recomposition...
    6. ... which will start with 1. and you are stuck in an infinite loop.

    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:

    1. 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.

    2. 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.

    3. 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.

    4. Actually observing uiState with collectAsStateWithLifecycle()

      When 3. finishes the coroutine that should observe uiState is now executed.

    5. The first observed uiState is FileUiState.Loaded because observation started after the file was read.

    6. 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.