内容简介:Managing UI state is one of the most important things you can do as an app developer. Done wrong, state management causes problems like poor error handling, hard-to-read code and a lack of separation between UI and business logic.With Kotlin,In this tutori
Managing UI state is one of the most important things you can do as an app developer. Done wrong, state management causes problems like poor error handling, hard-to-read code and a lack of separation between UI and business logic.
With Kotlin, sealed classes make it simpler to manage state.
In this tutorial, you’ll learn the advantages of Kotlin sealed classes and how to leverage them to manage states in your Android apps.
You’ll do this by building the RickyCharacters app, which displays a list of characters from the television show. You’ll download the character data from The Rick And Morty API and manage states in the app by using the Retrofit library to make network calls.
If you’re unfamiliar with Kotlin, take a look at our Kotlin For Android: An Introduction tutorial.
To brush up on your Android skills, check out our Android and Kotlin tutorials .
You can also learn more about sealed classes in our Kotlin Sealed Classes tutorial.
Getting Started
Download the begin project by clicking the Download Materials button at the top or bottom of the tutorial.
Extract the zip file and open the begin project in Android Studio by selecting Open an existing Android Studio project from the welcome screen. Navigate to the begin directory and click Open .
Once the Gradle sync finishes, build and run the project using the emulator or a device. You’ll see this screen appear:
You probably expected some Rick and Morty Characters on the home screen, but that feature isn’t ready yet. You’ll add the logic to fetch the character images later in this tutorial.
Advantages of Sealed Classes
Sealed classes are a more powerful extension of enums. Here are some of their most powerful features.
Multiple Instances
While an enum constant exists only as a single instance, a subclass of a sealed class can have multiple instances. That allows objects from sealed classes to contain state.
Look at the following example:
sealed class Months { class January(var shortHand: String) : Months() class February(var number: Int) : Months() class March(var shortHand: String, var number: Int) : Months() }
Now you can create two different instances of February
. For example, you can pass the 2019
as an argument to first instance, and 2020
to second instance, and compare them.
Inheritance
You can’t inherit from enum classes, but you can from sealed classes.
Here’s an example:
sealed class Result { data class Success(val data : List<String>) : Result() data class Failure(val exception : String) : Result() }
Both Success
and Failure
inherit from the Result
sealed class in the code above.
Kotlin 1.1 added the possibility for data classes to extend other classes, including sealed classes.
Architecture Compatibility
Sealed classes are compatible with commonly-used app architectures, including:
- MVI
- MVVM
- Redux
- Repository pattern
This ensures that you don’t have to change your existing app architecture to leverage the advantages of sealed classes.
“When” Expressions
Kotlin lets you use when
expressions with your sealed classes. When you use these with the Result
sealed class, you can parse a result based on whether it was a success or failure.
Here’s how this might look:
when (result) { is Result.Success -> showItems(result.data) is Result.Failure -> showError(result.exception) }
This has a few advantages. First, you can check the type of result
using Kotlin’s is
operator. By checking the type, Kotlin can smart-cast the value of result
for you for each case.
If the result is a success, you can access the value as if it’s Result.Success
. Now, you can pass items without any typecasting to showItems(data: List
.
If the result was a failure, you display an error to the user.
Another way you can use the when
expression is to exhaust all the possibilities for the Result
sealed class type.
Typically, a when
expression must have an else
clause. In the example above, however, there are no other possible types for Result
. The compiler and IDE know you don’t need an else
clause.
Look what happens when you add an InvalidData
object to the sealed class:
sealed class Result { data class Success(val data : List<String>) : Result() data class Failure(val exception : String) : Result() object InvalidData : Result() }
The IDE now shows an error on the when statement because you haven’t handled the InvalidData
branch of Result
.
The IDE knows you didn’t cover all your cases here. It even knows which possible types result
could be, depending on the Result
sealed class. The IDE offers you a quick fix to add the missing branches.
Note : You only make a when
expression exhaustive if you use it as an expression, you use its return type or you call a function on it.
Managing State in Android
Fasten your seat belt, as you’re about to travel back in time to a multiverse where sealed classes did not exist. Get ready to see how Rick and Morty survived back then.
Classical State Management
Your goal is to fetch a list of characters for the “Rick and Morty” television show from The Rick And Morty API and display them in a Recycler view.
Open CharactersFragment.kt in com.raywenderlich.android.rickycharacters.ui.views.fragments.classicalway and you’ll see the following code:
class CharactersFragment : Fragment(R.layout.fragment_characters) { // 1 private val apiService = ApiClient().getClient().create(ApiService::class.java) private lateinit var charactersAdapter: CharactersAdapter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // 2 charactersAdapter = CharactersAdapter {character -> displayCharacterDetails(character) } recyclerViewMovies.adapter = charactersAdapter fetchCharacters() swipeContainer.setOnRefreshListener { fetchCharacters() } } // 3 private fun displayCharacterDetails(character: Character) { val characterFragmentAction = CharactersFragmentDirections.actionCharactersFragmentToCharacterDetailsFragment( character) findNavController().navigate(characterFragmentAction) }
To explain the code above:
- You have two variables at the very top. One represents the
retrofit
service class and the other represents therecyclerview
adapter class. - Inside
onViewCreated
, you initialize the adapter class and set the adapter for the Recycler view, which displays the list of characters. There’s a call tofetchCharacters
and you also set a refresh listener to theSwipeRefeshLayout
that calls thefetchCharacters
on refresh. -
displayCharacterDetails(character: Character)
is responsible for navigating to the CharacterDetailsFragment and showing character details once you tap on each character.
Below displayCharactersDetails(character: Characters)
, there’s more code responsible for requesting the characters and displaying the appropriate UI.
// 1 private fun fetchCharacters() { //TODO 1 Make a get characters Request //TODO 2 Catch errors with else statement //TODO 3 Catch errors with try-catch statement //TODO 4 Catch HTTP error codes //TODO 5 Add refresh dialog //TODO 6 Handle null response body } // 2 private fun showCharacters(charactersResponseModel: CharactersResponseModel?) { charactersAdapter.updateList(charactersResponseModel!!.results) } // 3 private fun handleError(message : String) { errorMessageText.text = message } // 4 private fun showEmptyView() { emptyViewLinear.show() recyclerViewMovies.hide() hideRefreshDialog() } // 5 private fun hideEmptyView() { emptyViewLinear.hide() recyclerViewMovies.show() hideRefreshDialog() } // 6 private fun showRefreshDialog() { swipeContainer.isRefreshing = true } // 7 private fun hideRefreshDialog() { swipeContainer.isRefreshing = false }
Here’s an explanation for the code above:
-
fetchCharacters()
is responsible for fetching the characters from the api and handling the response. There’s a couple of //TODOs here, which you’ll address one by one in this tutorial. -
showCharacters(charactersResponseModel: CharactersResponseModel?)
takesCharacterResponseModel
as an argument. This is a data class representing the response from the Rick and Morty API. The function sends the list of characters to theCharactersAdapter
. -
handleError(message : String)
takes aString
as an argument and sets the message to aTextView
. -
showEmptyView()
hides the Recycler view and shows the empty view. This is a linear layout with an image and an error text view for displaying the error message. NoticerecyclerViewMovies.hide()
uses an extension function from com.raywenderlich.android.rickycharacters.utils.extensions.kt . This is also where you find theshow()
extension function. -
hideEmptyView()
hides the empty view and shows the Recycler view. -
showRefreshDialog()
sets the refresh property of SwipeRefreshLayout totrue
. -
hideRefreshDialog
sets the refresh property of SwipeRefreshLayout tofalse
.
With the code in this class explained, we’re ready to begin coding. In the next section, you’ll add begin to request characters from the Rick and Morty API. Time to get schwifty!
Requesting the Characters
Start by replacing the first TODO in fetchCharacters()
with the following:
lifecycleScope.launchWhenStarted { val response = apiService.getCharacters() val charactersResponseModel = response.body() if (response.isSuccessful){ hideEmptyView() showCharacters(charactersResponseModel) } }
Note: lifecycleScope throws an error when added, because it needs an imported class to work To fix this, just import the missing class.
This function has a couple of important components:
- First, there’s
lifecycleScope.launchWhenStarted{}
, aCoroutineScope
from the architecture components that’s lifecycle-aware. It launches a coroutine to perform operations on the background thread that let you make a network call using Retrofit. The project has the Retrofit part already set up for you, along with all its required classes and interfaces. - Inside the
lifecycleScope
, you make the network call. You useresponse
, which callsgetCharacters()
from Retrofit’s ApiService class to get a list of characterscharactersResponseModel
derives its value from the response body of the network call. - Finally, you check if the response is successful and call
showCharacters(charactersResponseModel)
.
Note: Take a look at the Coroutines documentation for more info about Coroutines.
Build and run and you’ll see the list of Rick and Morty characters:
Hurray, the app runs as expected. But there’s a problem: With this kind of approach, this is what happens when an error occurs:
In classic state management, errors completely crash your app.
Addressing Errors
Your next thought might be to catch all errors with an else
statement.
To try this, add the following catchall else
statement for //TODO 2 :
else { handleError("An error occurred") }
To reproduce an error, navigate to data/network/ApiService.kt and make the following change to the @GET
call:
@GET("/api/character/rrr")
Notice the addition at the end of the path. Your app won’t like that.
Now, build and run and the app will show errors:
Though you might think that else
has rescued you, a closer look reveals some errors that else
doesn’t handle – and they crash the app.
The errors that you have not yet handled are HTTP errors, network exceptions and null responses from the API.
Your next step will be to handle these kinds of errors.
Using a Try-Catch Statement to Catch Errors
If else
isn’t robust enough to catch all the errors, maybe a try-catch
statement might do the trick? You’ll try that next.
For //TODO 3 , modify the entire code in fetchCharacters
with a try-catch
. The code in the method should look as follows:
lifecycleScope.launchWhenStarted { try { val response = apiService.getCharacters() val charactersResponseModel = response.body() if (response.isSuccessful) { hideEmptyView() showCharacters(charactersResponseModel) } else { handleError("An error occurred") } } catch (error: IOException) { showEmptyView() handleError(error.message!!) } }
Note: IOException
throws an error when added, just import the missing class to fix this.
Here, the try-catch
makes sure the app no longer crashes. It displays an error instead.
Let’s try this out. Make sure your device has no internet connection, then build and run the app. Swipe down to begin requesting characters, shortly, you’ll see the following:
The app no longer crashes when there’s no internet connection. Instead, it displays the Unable to resolve host
error message.
However, you have only addressed one type of error, making it hard to know what’s gone wrong, especially for the case of HTTP errors. You’ll address that problem in the next section.
Handling HTTP Errors
Continue testing your states and and you’ll realize that there are several different HTTP errors that help you know what the problem is.
With the current approach, you won’t see what’s causing the problem because you’ll only get the generic error message: “An error occurred”.
To catch more of the HTTP errors, replace the code within the else
branch in fetchCharacters
with this:
showEmptyView() when(response.code()) { 403 -> handleError("Access to resource is forbidden") 404 -> handleError("Resource not found") 500 -> handleError("Internal server error") 502 -> handleError("Bad Gateway") 301 -> handleError("Resource has been removed permanently") 302 -> handleError("Resource moved, but has been found") else -> handleError("All cases have not been covered!!") }
The code block has a when
statement, which takes response.code()
from the network call as a parameter. It has some specific cases – 403, 404, 500, 502, 301 and 302 – plus the default else
, in case the code isn’t specified. For each case, you handle the error with the appropriate message for each HTTP code.
Navigate to data/network/ApiService.kt again and change the end of the @GET
call:
@GET("/api/character/rrr")
Notice the addition at the end of the path. Again, your app won’t like that.
Build and run and you’ll see an Internal server error message instead of the generic error message:
Congratulations, you can now catch HTTP errors and show the user what exactly went wrong instead of a generic message.
Now that you’ve handled your error message nicely, your next step will be to make the download experience more transparent to the user.
Note : Make sure you go back and undo the change on ApiService.kt to remove the addition that causes errors.
Indicating Download Progress
It’s not a great experience to click on an option to download data and get no response until the network call finishes. To make your app more user-friendly, your next step is to show a progress dialog so that users can know that the app is fetching data.
You’ll now address //TODO 5 by adding showRefreshDialog()
below fetchCharacters()
inside OnViewCreated
. Your code should look like the following:
lifecycleScope.launchWhenStarted { try { val response = apiService.getCharacters() val charactersResponseModel = response.body() if (response.isSuccessful) { hideEmptyView() showCharacters(charactersResponseModel) } else { showEmptyView() when(response.code()) { 403 -> handleError("Access to resource is forbidden") 404 -> handleError("Resource not found") 500 -> handleError("Internal server error") 502 -> handleError("Bad Gateway") 301 -> handleError("Resource has been removed permanently") 302 -> handleError("Resource moved, but has been found") else -> handleError("All cases have not been covered!!") } } } catch (error: IOException) { showEmptyView() handleError(error.message!!) } } showRefreshDialog()
Build and run to see the loading icon.
You’re almost done, but you have one more case to handle before your app is ready to go.
Handling Null Responses
As you finish managing state the classic way, you need to handle what happens when the success response is null. Handling this case is your //TODO 6 .
To address this, add a null check on the on successful response by replacing if (response.isSuccessful) {}
with this:
if (response.body() != null) { hideEmptyView() showCharacters(charactersResponseModel) } else { showEmptyView() handleError("No characters found") }
In the code above, you check if response.body()
is null
. If it’s not, hide the empty view and show the characters. If it is, you handle the error with a “No characters found” message.
The final results looks like this:
private fun fetchCharacters() { lifecycleScope.launchWhenStarted { try { val response = apiService.getCharacters() val charactersResponseModel = response.body() if (response.isSuccessful) { if (response.body() != null) { hideEmptyView() showCharacters(charactersResponseModel) } else { showEmptyView() handleError("No characters found") } } else { showEmptyView() when (response.code()) { 403 -> handleError("Access to resource is forbidden") 404 -> handleError("Resource not found") 500 -> handleError("Internal server error") 502 -> handleError("Bad Gateway") 301 -> handleError("Resource has been removed permanently") 302 -> handleError("Resource moved, but has been found") else -> handleError("All cases have not been covered!!") } } } catch (error : IOException) { showEmptyView() handleError(error.message!!) } }
Problems With Classical State Management
As you went implementing state using the classical approach, you may have noticed some problems with it. Including:
fetchCharacters
Luckily, Kotlin gives you a better way to handle states.
Simplifying States With Sealed Classes
Sealed classes can eliminate the problems associated with the old way of managing state.
Modeling States With Sealed Classes
With sealed classes, you think about all the possible states before you start to code. You then keep the end result in mind when you start to code.
In this example, you need to follow these states:
- Success with a list of characters.
- Invalid data – no characters found.
- Generic error state.
- Network exceptions – errors caused by network failure.
- HTTP errors that represent HTTP error codes. There can be more than one.
So with these states in mind, navigate to data ▸ states . Right-click and create a new Kotlin class named NetworkState
. Then add the following code:
sealed class NetworkState { data class Success(val data : CharactersResponseModel) : NetworkState() object InvalidData : NetworkState() data class Error(val error : String) : NetworkState() data class NetworkException(val error : String) : NetworkState() sealed class HttpErrors : NetworkState() { data class ResourceForbidden(val exception: String) : HttpErrors() data class ResourceNotFound(val exception: String) : HttpErrors() data class InternalServerError(val exception: String) : HttpErrors() data class BadGateWay(val exception: String) : HttpErrors() data class ResourceRemoved(val exception: String) : HttpErrors() data class RemovedResourceFound(val exception: String) : HttpErrors() } }
The types in the sealed class are data classes, objects and another sealed class representing the HTTP error states.
Now that you have all the states ready, you’re ready to apply the states to your network call.
Applying the States to Your App
Navigate to ui ▸ views ▸ fragments ▸ sealedclassway . Open StateCharactersFragment.kt Replace getCharacters
with the following:
private fun getCharacters() { lifecycleScope.launchWhenStarted { showRefreshDialog() val charactersResult = fetchCharacters() handleCharactersResult(charactersResult) } }
Here you’re setting up the Fragment to show it’s in a loading state, via showRefreshDialog
, whilst the app makes a network request through fetchCharacters()
. You handle the result of the request through handleCharactersResult
.
Next, you need to write the fetchCharacters
method to request the characters from the API. Update the fetchCharacters
like so:
private suspend fun fetchCharacters() : NetworkState { return try { val response = apiService.getCharacters() if (response.isSuccessful) { if (response != null) { NetworkState.Success(response.body()!!) } else { NetworkState.InvalidData } } else { when(response.code()) { 403 -> NetworkState.HttpErrors.ResourceForbidden(response.message()) 404 -> NetworkState.HttpErrors.ResourceNotFound(response.message()) 500 -> NetworkState.HttpErrors.InternalServerError(response.message()) 502 -> NetworkState.HttpErrors.BadGateWay(response.message()) 301 -> NetworkState.HttpErrors.ResourceRemoved(response.message()) 302 -> NetworkState.HttpErrors.RemovedResourceFound(response.message()) else -> NetworkState.Error(response.message()) } } } catch (error : IOException) { NetworkState.NetworkException(error.message!!) } }
After adding this, make sure to import the NetworkState
and IOException
classes.
There are a few differences here from the classical approach. The function has a suspend
keyword, which means it can pause and resume later when you have a response from the server.
All the condition checks are the same as before, but now instead of handling the results, you are assigning variables to NetworkState
depending on the response.
For a success response, you check if the response is null
or not. If it’s not, you set the state as NetworkState.Success(response.body()!!)
.
Notice the success state takes the response body and sets the state to NetworkState.InvalidData
if its null
. If the response is not successful, you handle the error along with the HTTP errors.
For HTTP errors, you set the state to NetworkState.HttpErrors
, depending on the error code. For normal errors, you set the state to NetworkState.Error(response.message())
. In the catch block, you set the state to NetworkState.NetworkException(error.message!!)
.
Notice all these states have variables that can have more than one value, which is one of the advantages of sealed states.
Also, this function only deals with fetching data and updating the states, it contains no business logic or UI logic. This helps the method be focused on doing one thing and makes the code much more readable.
To display the results, add the following function to the fragment just below fetchCharacters()
:
private fun handleCharactersResult(networkState: NetworkState) { return when(networkState) { is NetworkState.Success -> showCharacters(networkState.data) is NetworkState.HttpErrors.ResourceForbidden -> handleError(networkState.exception) is NetworkState.HttpErrors.ResourceNotFound -> handleError(networkState.exception) is NetworkState.HttpErrors.InternalServerError -> handleError(networkState.exception) is NetworkState.HttpErrors.BadGateWay -> handleError(networkState.exception) is NetworkState.HttpErrors.ResourceRemoved -> handleError(networkState.exception) is NetworkState.HttpErrors.RemovedResourceFound -> handleError(networkState.exception) is NetworkState.InvalidData -> showEmptyView() is NetworkState.Error -> handleError(networkState.error) is NetworkState.NetworkException -> handleError(networkState.error) } }
This function takes NetworkState
as an argument and uses a when
expression as a return statement to give the exhaustive advantage. Since each state exists independent of the others, you handle all the possible states.
Displaying the Character Details
Now you’ve converted the network call code to use sealed classes, let’s hook up the UI. Since fetchCharacters
is a suspend function call, we’ll need to wrap calls to it in a coroutine. In getCharacters
wrap the calls to getCharacters()
in lifecycle scope coroutine like so:
lifecycleScope.launchWhenStarted { hideEmptyView() showRefreshDialog() val charactersResult = getCharacters() handleCharactersResult(charactersResult) }
Also in onViewCreated
, add a lifecycleScope within the refresh listener:
swipeContainer.setOnRefreshListener { lifecycleScope.launchWhenStarted { getCharacters() } }
Also add the lifecycle
import when prompted.
Here, you call showRefreshDialog()
to show the refresh dialog whilst the app fetches the characters from the api. characterResult
calls getCharacters()
to make a network request to fetch the characters, then the next line calls handleCharactersResult(charactersResult)
with the characterResult
variable.
Finally, we need to hook up our UI to use the new Fragment, as it’ll continue to use CharactersFragment
instead of our shiny new StateCharactersFragment
. Navigate to res ▸ navigation and open main_nav_graph.xml .
Choose the Text tab and change startDestination
at the navigation
tags with stateCharactersFragment
.
<navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/main_nav_graph" app:startDestination="@id/stateCharactersFragment">
Now, build and run.
Behold, the app runs well, all states properly managed. The character list displays properly:
The character detail screen works, as well:
Congratulations! You’ve completed your app. In the process, you learned about the features and advantages of sealed classes and how to use them in Android to tame states.
Where to Go From Here?
You can download the begin and end projects by using the Download Materials button at the top or bottom of the tutorial.
There’s much you can do with sealed classes in state management. If you want to dive deeper into the subject, you can read our Kotlin Sealed Classes tutorial.
This tutorial touched on several other concepts that were outside the scope of this article.
To learn more about coroutines in Kotlin check out our Kotlin Coroutines Tutorial for Android: Getting Started tutorial.
You can also take a deeper look at how to create clean architecture for your apps in our Clean Architecture for Android tutorial.
We hope you enjoyed this Beyond the Basics with Sealed Classes tutorial. If you have any questions, comments or awesome modifications to this project app, please join the forum discussion below.
以上所述就是小编给大家介绍的《Kotlin and Android: Beyond the Basics with Sealed Classes [FREE]》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Mashups Web 2.0开发技术—— 基于Amazon.com
萨拉汉 / 吴宏泉 / 清华大学 / 2008-1 / 48.00元
《MashupsWeb2.0开发技术(基于Amazon.Com) 》介绍了mashup的底层技术,并且第一次展示了如何创建mashup的应用程序。Amazon.com与Web服务强势结合,拓展了Internet的应用范围,使得开发人员可以把Amazon的数据和其他的可利用资源自由地结合起来创建功能丰富的全新应用程序,这种应用程序叫做mashup。 《MashupsWeb2.0开发技术(基于A......一起来看看 《Mashups Web 2.0开发技术—— 基于Amazon.com》 这本书的介绍吧!