Lab 6 - Background Threads, Coroutines and ViewModel
The starting point for this lab is this project - download and open it.
The MainActivity UI has a Spinner (a dropdown), an ImageView, and 3 buttons. The code also takes care of permission management for you.
Your task is to display a list of images from the device's external storage "Pictures" directory in the spinner. When a file is selected in the spinner, then clicking a button should show the file in the ImageView, using different methods - worker threads, coroutines or run on UI thread (depending on which button was pressed).
To help you out, there's also a MediaStoreUtils class. It has one function, which loads a list of images for you. It uses MediaStore ContentProvider to get a list of image file descriptions (not the full files themselves!). (More info). You can directly use this function, you don't need to change it for this lab.
0. Add some Image files to your device
Before we start coding, let's make sure we actually have some images in our "Pictures" folder.
If using the emulator, you have 2 options:
- Android Studio's Device File explorer.
- Navigate to the
sdcard/Pictures folder
and upload some files by right-clicking. - Important: pushing files like this does not refresh the MediaStore database immediately . For that to happen, after adding the files, you should reboot the device or run a Cold Boot (find the option from AVD Manager)
- Navigate to the
- Drag-and-drop
- Drag an image file from the host machine to the emulator window, this places them in the "Downloads" folder. After drag-and-dropping, you should move them from "Downloads" to "Pictures" (using the emulator's Files App).
Push a few image files of different sizes to the Pictures folder. I recommend including larger images, too (this one, which is over 20MB, for example - by wikimedia user Abrget47j ). Find more nice high-quality images here
1. Setting up Spinner and Files
The following should be done in MainActivity.setUpSpinner()
- Use
MediaStoreUtils.loadImagesFromMediaStore( )
to get a list of Image file descriptions. Note that Image is a custom data class in the project. - Create an ArrayAdapter<Image>, using 3 arguments:
- context (this)
- The layout resource
android.R.layout.simple_spinner_dropdown_item
- file list object we just acquired
- Set the spinners adapter to the arrayAdapter
<<my_spinner_id>>
.adapter =<<myAdapter>>
You should now be able to see some files in your apps external Pictures folder appearing in the spinner
However, the implementation of ArrayAdapter calls toString() for the objects in the array when displaying them, and toString() of data class shows all the properties of the class, which isn't always useful (e.g. the URI is not something the user should be concerned about).
- Override toString() of Image so that instead, it returns the name and size of the file.
2. Loading image into ImageView
Let's first read the file and display it on the UI thread. Update onMainThreadClicked(), so that it fetches the selected image, turns it into a Bitmap and then updates the ImageView with the Bitmap:
- Get the selected image in the spinner
- Using
spinner.selectedItem as Image
- Using
- call a function loadImage() (we will create this function next), with the selected Image as an argument, and stores the returned value of the function into a variable of type Bitmap.
- Update the imageView with the Bitmap.
- chosenfileImageView.setImageBitmap(bitmap)
We need to implement the new function loadImage(image: Image): Bitmap
:
- Get the file contents as a stream of bytes using
contentResolver.openInputStream(selectedImage.uri)
. This loads the stream based on the provided URI:- use Kotlin's
.use { }
keyword to open and close the stream.
- use Kotlin's
- To initialize a Bitmap from the stream, use
BitmapFactory.decodeStream(<<stream>>)
- Since very large images can cause crashes, let's scale the image down before returning it:
val ratio = fullBitmap.width.toDouble() / fullBitmap.height val scaledBitmap = Bitmap.createScaledBitmap(fullBitmap, (800*ratio).toInt(), 800, false)
- return the scaled bitmap.
- Update onMainThreadClicked() so that it calls loadImage() function with the image selected from the spinner as the argument.
- Display the result of loadImage() in ImageView, using
chosenfile_imageview.setImageBitmap(bitmap)
Run the application and verify that you can see the picture appear in the ImageView after clicking on the Main Thread button. Notice that if using larger files, the entire UI freezes while the Bitmap is being loaded. Also try what happens if you read a large image (20MB+) and try to display it without scaling down first.
2.1 Running in background thread
Finish the code for onWorkerThreadClicked(), similarly loading & displaying the image, except this time, call loadImage() inside a background thread.
- Threads can be created with:
thread(start=true){ .. }
- Note by default, the start parameter is true, so it is optional in this case.
- To update the UI from inside the thread, you can use
<<activityContext>>.runOnUiThread { // some work }
or<<some_ui_view_object>>.post { // some work }
3. Running with Coroutines
Let's implement onCouroutinesClicked. To use coroutines, we have to add the following dependency to the project:
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1'
A very basic usage of coroutines would be:
val scope = CoroutineScope(Dispatchers.Default) scope.launch { //do some work }
The code within the launch { }
block will run inside a coroutine, within the defined scope. The scope also determines which thread the coroutine will run on by specifying the dispatcher.
If you use any Dispatcher other than Dispatcher.Main, you still have to add something like runOnUiThread{ .. } to the coroutine to be able to update the UI.
Instead of using runOnUiThread{ .. }, let's make a new version of loadImage(), called loadImageSuspended(), this will be main-safe- meaning that it can be called from the main thread but it will handle running the long-running work asynchronously itself, without blocking the main thread.
- Create the function loadImageSuspended(), having an identical signature like loadImage().
- the function should internally call loadImage() with the same parameters, but the call should be happen within a
withContext(<<Dispatcher>>){ .. }
block.
- the function should internally call loadImage() with the same parameters, but the call should be happen within a
- withContext will change the dispatcher within the running coroutine. Use it to change to the IO dispatcher:
suspend fun loadImageSuspended(selectedImage: Image): Bitmap { return withContext(Dispatchers.IO){ // do work, load the image } }
withContext is a suspend function, and every suspend function has to either be called from a suspend function or be run in a coroutine. This is why loadImageSuspended should also have the suspend keyword.
- Now update onCouroutinesClicked(), so that it calls loadImageSuspended() from a coroutine using the Main thread dispatcher.
- use the returned Bitmap to update the ImageView again. Since the routine is run with the Main thread dispatcher, you can directly update the ImageView, without using runOnUiThread{}
4 Refactoring the code and LiveData
Let's try to refactor and organize the code a bit. Our goal is to move the code concerned with loading and preparing the data (images) for the UI to a ViewModel.
- Create a new AndroidViewModel class, call it MainViewModel.
class MainViewModel(app: Application): AndroidViewModel(app) { .. }
We are using AndroidViewModel instead of ViewModel, because we want to start loading the files from the ViewModel, instead of the Activity. To do that, we need access the application context, which AndroidViewModel provides, but ViewModel doesn't.
4.1 LiveData
- Add this property to your ViewModel:
val displayedImage: MutableLiveData<Bitmap> = MutableLiveData()
We want to structure our code so that the different workers/coroutines can just update this variable, and the UI will update the View widgets automatically whenever the value changes.
This is what LiveData is designed for. It helps with separating code layers which deal with presenting the data from the layers that fetch/manage/update the data.
- Let's move the loadImage(), loadImageSuspended() functions from MainActivity to MainViewModel.
- You have to update the references to context. In the ViewModel, use
getApplication<Application>()
- You have to update the references to context. In the ViewModel, use
This broke some code in MainActivity.
- Let's make the ViewModel available to MainActivity:
- Get an instance of viewModel:
// in MainActivity: val model = ViewModelProvider(this).get(MainViewModel::class.java)
- Now, you can fix the broken code in MainActivity by calling the loadImage methods from the model (you may need to remove the private modifier from functions if you have them )
- Add a function "chooseImage(image: Image)" to the ViewModel.
- Similar to "onCoroutineClicked", the function should call loadImageSuspended() within a coroutine.
- Note: ViewModels provide their own couroutine scope, called viewModelScope, which you should use instead of creating your own scope:
viewModelScope.launch { .. }
- The resulting Bitmap of loadImageSuspended() should be set to be the new value of the LiveData (instead of directly updating the UI, as we did in MainActivity):
displayedImage.value = bmp // update the MutableLiveData using .setValue()
Now, when the image is loaded, it is set as the value of LiveData. Let's make MainActivity notice when this happens and react to it.
4.2 Observer
We will use the observer pattern, which means that Activity will attach an Observer to the LiveData, and whenever LiveData is updated, the observer's callback is invoked.
In onCreate() (or a function called from onCreate() ):
// create the observer val chosenImageObserver = Observer<Bitmap> { newImage -> chosenfile_imageview.setImageBitmap(newImage) } // Observe the LiveData, passing this activity as the LifecycleOwner and the observer. model.displayedImage.observe(this, chosenImageObserver)
LiveData will only update observers whose lifecycle is active.
- Update 'onCoroutineClicked()' so it that calls model.chooseImageCoroutines(..) (based on spinner item) and does not do anything else.
- Verify that the Image loading and ImageView updating works.
Try to refactor the rest of the code to move the threaded version also into viewmodel. For example, you can create another function chooseImageThreaded() in the ViewModel. You can also move the dataset which the adapter uses to the ViewModel, and refer to the model when initializing the adapter in MainActivity:
// new property in viewmodel: var imageList: MutableList<Image> = mutableListOf() init { imageList = Utils.loadImagesFromMediaStore(getApplication()) }
Once you're done, notice how by using ViewModel and LiveData, the code in ViewModel knows virtually nothing about our UI - it is purely operating with managing the data. Meanwhile, the Activity is operating on working with the Android UI SDK.