Umgang mit RESTful-Webdiensten mit Retrofit, OkHttp, Gson, Glide und Coroutines

Kriptofolio App-Serie - Teil 5

Heutzutage stellt fast jede Android-App eine Verbindung zum Internet her, um Daten abzurufen / zu senden. Sie sollten auf jeden Fall lernen, wie Sie mit RESTful Web Services umgehen, da deren korrekte Implementierung das Kernwissen bei der Erstellung moderner Apps ist.

Dieser Teil wird kompliziert sein. Wir werden mehrere Bibliotheken gleichzeitig kombinieren, um ein funktionierendes Ergebnis zu erhalten. Ich werde nicht über die native Android-Methode zur Bearbeitung von Internetanfragen sprechen, da sie in der realen Welt von niemandem verwendet wird. Jede gute App versucht nicht, das Rad neu zu erfinden, sondern verwendet stattdessen die beliebtesten Bibliotheken von Drittanbietern, um häufig auftretende Probleme zu lösen. Es wäre zu kompliziert, die Funktionalität dieser gut gemachten Bibliotheken wiederherzustellen.

Serieninhalt

  • Einführung: Eine Roadmap zum Erstellen einer modernen Android-App in den Jahren 2018–2019
  • Teil 1: Eine Einführung in die SOLID-Prinzipien
  • Teil 2: So starten Sie Ihre Android-App: Erstellen von Mockups, UI- und XML-Layouts
  • Teil 3: Alles über diese Architektur: Erkundung verschiedener Architekturmuster und deren Verwendung in Ihrer App
  • Teil 4: Implementieren von Dependency Injection in Ihrer App mit Dagger 2
  • Teil 5: Behandeln Sie RESTful-Webdienste mit Retrofit, OkHttp, Gson, Glide und Coroutines (Sie sind hier)

Was ist Retrofit, OkHttp und Gson?

Retrofit ist ein REST-Client für Java und Android. Diese Bibliothek ist meiner Meinung nach die wichtigste, die es zu lernen gilt, da sie die Hauptaufgabe erfüllen wird. Es macht es relativ einfach, JSON (oder andere strukturierte Daten) über einen REST-basierten Webservice abzurufen und hochzuladen.

In Retrofit konfigurieren Sie, welcher Konverter für die Datenserialisierung verwendet wird. Normalerweise verwenden Sie zum Serialisieren und Deserialisieren von Objekten zu und von JSON eine Open-Source-Java-Bibliothek - Gson. Bei Bedarf können Sie Retrofit auch benutzerdefinierte Konverter hinzufügen, um XML oder andere Protokolle zu verarbeiten.

Für HTTP-Anfragen verwendet Retrofit die OkHttp-Bibliothek. OkHttp ist ein reiner HTTP / SPDY-Client, der für alle Netzwerkoperationen, Caching-, Anforderungs- und Antwortmanipulationen auf niedriger Ebene verantwortlich ist. Im Gegensatz dazu ist Retrofit eine REST-Abstraktion auf hoher Ebene, die auf OkHttp aufbaut. Retrofit ist stark mit OkHttp gekoppelt und nutzt es intensiv.

Jetzt, da Sie wissen, dass alles eng miteinander verbunden ist, werden wir alle diese 3 Bibliotheken gleichzeitig verwenden. Unser erstes Ziel ist es, alle Kryptowährungen mit Retrofit aus dem Internet abzurufen. Wir werden eine spezielle OkHttp-Interceptor-Klasse für die CoinMarketCap-API-Authentifizierung verwenden, wenn Sie den Server anrufen. Wir erhalten ein JSON-Datenergebnis zurück und konvertieren es dann mithilfe der Gson-Bibliothek.

Schnelle Einrichtung für Retrofit 2, nur um es zuerst zu versuchen

Wenn ich etwas Neues lerne, probiere ich es gerne so schnell wie möglich in der Praxis aus. Wir werden mit Retrofit 2 einen ähnlichen Ansatz anwenden, damit Sie ihn schneller besser verstehen. Machen Sie sich im Moment keine Sorgen um die Codequalität oder Programmierprinzipien oder -optimierungen. Wir schreiben nur Code, damit Retrofit 2 in unserem Projekt funktioniert, und besprechen, was es bewirkt.

Führen Sie die folgenden Schritte aus, um Retrofit 2 für das App-Projekt My Crypto Coins einzurichten:

Geben Sie zunächst INTERNET die Berechtigung für die App

Wir werden HTTP-Anfragen auf einem Server ausführen, auf den über das Internet zugegriffen werden kann. Geben Sie diese Berechtigung, indem Sie diese Zeilen zu Ihrer Manifest-Datei hinzufügen:

  ... 

Dann sollten Sie Bibliotheksabhängigkeiten hinzufügen

Finden Sie die neueste Retrofit-Version. Außerdem sollten Sie wissen, dass Retrofit nicht mit einem integrierten JSON-Konverter geliefert wird. Da wir Antworten im JSON-Format erhalten, müssen wir den Konverter auch manuell in die Abhängigkeiten einbeziehen. Wir werden die neueste JSON-Konverter-Gson-Version von Google verwenden. Fügen wir diese Zeilen zu Ihrer Gradle-Datei hinzu:

// 3rd party // HTTP client - Retrofit with OkHttp implementation "com.squareup.retrofit2:retrofit:$versions.retrofit" // JSON converter Gson for JSON to Java object mapping implementation "com.squareup.retrofit2:converter-gson:$versions.retrofit"

Wie Sie aus meinem Kommentar bemerkt haben, wird die OkHttp-Abhängigkeit bereits mit der Retrofit 2-Abhängigkeit ausgeliefert. Versionen sind der Einfachheit halber nur eine separate Gradle-Datei:

def versions = [:] versions.retrofit = "2.4.0" ext.versions = versions

Richten Sie als Nächstes die Nachrüstschnittstelle ein

Es ist eine Schnittstelle, die unsere Anfragen und ihre Typen deklariert. Hier definieren wir die API auf der Client-Seite.

/** * REST API access points. */ interface ApiService { // The @GET annotation tells retrofit that this request is a get type request. // The string value tells retrofit that the path of this request is // baseUrl + v1/cryptocurrency/listings/latest + query parameter. @GET("v1/cryptocurrency/listings/latest") // Annotation @Query is used to define query parameter for request. Finally the request url will // look like that //sandbox-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?convert=EUR. fun getAllCryptocurrencies(@Query("convert") currency: String): Call // The return type for this function is Call with its type CryptocurrenciesLatest. }

Und richten Sie die Datenklasse ein

Datenklassen sind POJOs (Plain Old Java Objects), die die Antworten der API-Aufrufe darstellen, die wir ausführen werden.

/** * Data class to handle the response from the server. */ data class CryptocurrenciesLatest( val status: Status, val data: List ) { data class Data( val id: Int, val name: String, val symbol: String, val slug: String, // The annotation to a model property lets you pass the serialized and deserialized // name as a string. This is useful if you don't want your model class and the JSON // to have identical naming. @SerializedName("circulating_supply") val circulatingSupply: Double, @SerializedName("total_supply") val totalSupply: Double, @SerializedName("max_supply") val maxSupply: Double, @SerializedName("date_added") val dateAdded: String, @SerializedName("num_market_pairs") val numMarketPairs: Int, @SerializedName("cmc_rank") val cmcRank: Int, @SerializedName("last_updated") val lastUpdated: String, val quote: Quote ) { data class Quote( // For additional option during deserialization you can specify value or alternative // values. Gson will check the JSON for all names we specify and try to find one to // map it to the annotated property. @SerializedName(value = "USD", alternate = ["AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "CZK", "DKK", "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PKR", "PLN", "RUB", "SEK", "SGD", "THB", "TRY", "TWD", "ZAR"]) val currency: Currency ) { data class Currency( val price: Double, @SerializedName("volume_24h") val volume24h: Double, @SerializedName("percent_change_1h") val percentChange1h: Double, @SerializedName("percent_change_24h") val percentChange24h: Double, @SerializedName("percent_change_7d") val percentChange7d: Double, @SerializedName("market_cap") val marketCap: Double, @SerializedName("last_updated") val lastUpdated: String ) } } data class Status( val timestamp: String, @SerializedName("error_code") val errorCode: Int, @SerializedName("error_message") val errorMessage: String, val elapsed: Int, @SerializedName("credit_count") val creditCount: Int ) }

Erstellen Sie eine spezielle Interceptor-Klasse für die Authentifizierung, wenn Sie den Server anrufen

Dies ist insbesondere bei APIs der Fall, für die eine Authentifizierung erforderlich ist, um eine erfolgreiche Antwort zu erhalten. Interceptors sind eine leistungsstarke Möglichkeit, Ihre Anforderungen anzupassen. Wir werden die eigentliche Anfrage abfangen und einzelne Anforderungsheader hinzufügen, die den Aufruf mit einem API-Schlüssel validieren, der vom CoinMarketCap Professional API Developer Portal bereitgestellt wird. Um Ihre zu erhalten, müssen Sie sich dort registrieren.

/** * Interceptor used to intercept the actual request and * to supply your API Key in REST API calls via a custom header. */ class AuthenticationInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val newRequest = chain.request().newBuilder() // TODO: Use your API Key provided by CoinMarketCap Professional API Developer Portal. .addHeader("X-CMC_PRO_API_KEY", "CMC_PRO_API_KEY") .build() return chain.proceed(newRequest) } }

Fügen Sie diesen Code schließlich zu unserer Aktivität hinzu, damit Retrofit funktioniert

Ich wollte deine Hände so schnell wie möglich schmutzig machen, also habe ich alles an einem Ort aufbewahrt. Dies ist nicht der richtige Weg, aber der schnellste, um schnell ein visuelles Ergebnis zu sehen.

class AddSearchActivity : AppCompatActivity(), Injectable { private lateinit var listView: ListView private lateinit var listAdapter: AddSearchListAdapter ... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... // Later we will setup Retrofit correctly, but for now we do all in one place just for quick start. setupRetrofitTemporarily() } ... private fun setupRetrofitTemporarily() { // We need to prepare a custom OkHttp client because need to use our custom call interceptor. // to be able to authenticate our requests. val builder = OkHttpClient.Builder() // We add the interceptor to OkHttpClient. // It will add authentication headers to every call we make. builder.interceptors().add(AuthenticationInterceptor()) val client = builder.build() val api = Retrofit.Builder() // Create retrofit builder. .baseUrl("//sandbox-api.coinmarketcap.com/") // Base url for the api has to end with a slash. .addConverterFactory(GsonConverterFactory.create()) // Use GSON converter for JSON to POJO object mapping. .client(client) // Here we set the custom OkHttp client we just created. .build().create(ApiService::class.java) // We create an API using the interface we defined. val adapterData: MutableList = ArrayList() val currentFiatCurrencyCode = "EUR" // Let's make asynchronous network request to get all latest cryptocurrencies from the server. // For query parameter we pass "EUR" as we want to get prices in euros. val call = api.getAllCryptocurrencies("EUR") val result = call.enqueue(object : Callback { // You will always get a response even if something wrong went from the server. override fun onFailure(call: Call, t: Throwable) { Snackbar.make(findViewById(android.R.id.content), // Throwable will let us find the error if the call failed. "Call failed! " + t.localizedMessage, Snackbar.LENGTH_INDEFINITE).show() } override fun onResponse(call: Call, response: Response) { // Check if the response is successful, which means the request was successfully // received, understood, accepted and returned code in range [200..300). if (response.isSuccessful) { // If everything is OK, let the user know that. Toast.makeText([email protected], "Call OK.", Toast.LENGTH_LONG).show(); // Than quickly map server response data to the ListView adapter. val cryptocurrenciesLatest: CryptocurrenciesLatest? = response.body() cryptocurrenciesLatest!!.data.forEach { val cryptocurrency = Cryptocurrency(it.name, it.cmcRank.toShort(), 0.0, it.symbol, currentFiatCurrencyCode, it.quote.currency.price, 0.0, it.quote.currency.percentChange1h, it.quote.currency.percentChange7d, it.quote.currency.percentChange24h, 0.0) adapterData.add(cryptocurrency) } listView.visibility = View.VISIBLE listAdapter.setData(adapterData) } // Else if the response is unsuccessful it will be defined by some special HTTP // error code, which we can show for the user. else Snackbar.make(findViewById(android.R.id.content), "Call error with HTTP status code " + response.code() + "!", Snackbar.LENGTH_INDEFINITE).show() } }) } ... }

Sie können den Code hier erkunden. Denken Sie daran, dass dies nur eine erste vereinfachte Implementierungsversion ist, damit Sie die Idee besser verstehen.

Endgültiges korrektes Setup für Retrofit 2 mit OkHttp 3 und Gson

Ok, nach einem kurzen Experiment ist es Zeit, diese Retrofit-Implementierung auf die nächste Stufe zu bringen. Wir haben die Daten bereits erfolgreich, aber nicht korrekt erhalten. Wir vermissen die Zustände wie Laden, Fehler und Erfolg. Unser Code wird ohne Trennung von Bedenken gemischt. Es ist ein häufiger Fehler, den gesamten Code in eine Aktivität oder ein Fragment zu schreiben. Unsere Aktivitätsklasse basiert auf der Benutzeroberfläche und sollte nur Logik enthalten, die die Interaktionen zwischen Benutzeroberfläche und Betriebssystem behandelt.

Nach diesem schnellen Setup habe ich viel gearbeitet und viele Änderungen vorgenommen. Es macht keinen Sinn, den gesamten Code, der geändert wurde, in den Artikel einzufügen. Besser stattdessen sollten Sie das endgültige Teil 5 Code Repo hier durchsuchen. Ich habe alles sehr gut kommentiert und mein Code sollte klar sein, damit Sie ihn verstehen. Aber ich werde über die wichtigsten Dinge sprechen, die ich getan habe und warum ich sie getan habe.

Der erste Schritt zur Verbesserung bestand darin, Dependency Injection zu verwenden. Denken Sie daran, dass Dagger 2 im vorherigen Teil bereits korrekt im Projekt implementiert wurde. Also habe ich es für das Retrofit-Setup verwendet.

/** * AppModule will provide app-wide dependencies for a part of the application. * It should initialize objects used across our application, such as Room database, Retrofit, Shared Preference, etc. */ @Module(includes = [ViewModelsModule::class]) class AppModule() { ... @Provides @Singleton fun provideHttpClient(): OkHttpClient { // We need to prepare a custom OkHttp client because need to use our custom call interceptor. // to be able to authenticate our requests. val builder = OkHttpClient.Builder() // We add the interceptor to OkHttpClient. // It will add authentication headers to every call we make. builder.interceptors().add(AuthenticationInterceptor()) // Configure this client not to retry when a connectivity problem is encountered. builder.retryOnConnectionFailure(false) // Log requests and responses. // Add logging as the last interceptor, because this will also log the information which // you added or manipulated with previous interceptors to your request. builder.interceptors().add(HttpLoggingInterceptor().apply { // For production environment to enhance apps performance we will be skipping any // logging operation. We will show logs just for debug builds. level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE }) return builder.build() } @Provides @Singleton fun provideApiService(httpClient: OkHttpClient): ApiService { return Retrofit.Builder() // Create retrofit builder. .baseUrl(API_SERVICE_BASE_URL) // Base url for the api has to end with a slash. .addConverterFactory(GsonConverterFactory.create()) // Use GSON converter for JSON to POJO object mapping. .addCallAdapterFactory(LiveDataCallAdapterFactory()) .client(httpClient) // Here we set the custom OkHttp client we just created. .build().create(ApiService::class.java) // We create an API using the interface we defined. } ... }

Wie Sie sehen, wird Retrofit von der Aktivitätsklasse getrennt, wie es sein sollte. Es wird nur einmal initialisiert und app-weit verwendet.

Wie Sie vielleicht beim Erstellen der Retrofit Builder-Instanz bemerkt haben, haben wir einen speziellen Retrofit-Aufrufadapter mit hinzugefügt addCallAdapterFactory. Standardmäßig gibt Retrofit a zurück Call, aber für unser Projekt muss ein LiveDataTyp zurückgegeben werden. Dazu müssen wir LiveDataCallAdaptermit using hinzufügen LiveDataCallAdapterFactory.

/** * A Retrofit adapter that converts the Call into a LiveData of ApiResponse. * @param   */ class LiveDataCallAdapter(private val responseType: Type) : CallAdapter> { override fun responseType() = responseType override fun adapt(call: Call): LiveData { return object : LiveData() { private var started = AtomicBoolean(false) override fun onActive() { super.onActive() if (started.compareAndSet(false, true)) { call.enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { postValue(ApiResponse.create(response)) } override fun onFailure(call: Call, throwable: Throwable) { postValue(ApiResponse.create(throwable)) } }) } } } } }
class LiveDataCallAdapterFactory : CallAdapter.Factory() { override fun get( returnType: Type, annotations: Array, retrofit: Retrofit ): CallAdapter? { if (CallAdapter.Factory.getRawType(returnType) != LiveData::class.java) { return null } val observableType = CallAdapter.Factory.getParameterUpperBound(0, returnType as ParameterizedType) val rawObservableType = CallAdapter.Factory.getRawType(observableType) if (rawObservableType != ApiResponse::class.java) { throw IllegalArgumentException("type must be a resource") } if (observableType !is ParameterizedType) { throw IllegalArgumentException("resource must be parameterized") } val bodyType = CallAdapter.Factory.getParameterUpperBound(0, observableType) return LiveDataCallAdapter(bodyType) } }

Jetzt erhalten wir LiveDatastatt Callals Rückgabetyp von Retrofit-Servicemethoden, die in der ApiServiceSchnittstelle definiert sind .

Ein weiterer wichtiger Schritt ist die Verwendung des Repository-Musters. Ich habe in Teil 3 darüber gesprochen. Sehen Sie sich unser MVVM-Architekturschema aus diesem Beitrag an, um sich daran zu erinnern, wohin es führt.

Wie Sie auf dem Bild sehen, ist das Repository eine separate Ebene für die Daten. Es ist unsere einzige Kontaktquelle zum Abrufen oder Senden von Daten. Wenn wir Repository verwenden, folgen wir dem Prinzip der Trennung von Bedenken. Wir können verschiedene Datenquellen haben (wie in unserem Fall persistente Daten aus einer SQLite-Datenbank und Daten aus Webdiensten), aber das Repository wird immer eine einzige Quelle der Wahrheit für alle App-Daten sein.

Anstatt direkt mit unserer Retrofit-Implementierung zu kommunizieren, werden wir dafür das Repository verwenden. Für jede Art von Entität haben wir ein separates Repository.

/** * The class for managing multiple data sources. */ @Singleton class CryptocurrencyRepository @Inject constructor( private val context: Context, private val appExecutors: AppExecutors, private val myCryptocurrencyDao: MyCryptocurrencyDao, private val cryptocurrencyDao: CryptocurrencyDao, private val api: ApiService, private val sharedPreferences: SharedPreferences ) { // Just a simple helper variable to store selected fiat currency code during app lifecycle. // It is needed for main screen currency spinner. We set it to be same as in shared preferences. var selectedFiatCurrencyCode: String = getCurrentFiatCurrencyCode() ... // The Resource wrapping of LiveData is useful to update the UI based upon the state. fun getAllCryptocurrencyLiveDataResourceList(fiatCurrencyCode: String, shouldFetch: Boolean = false, callDelay: Long = 0): LiveData> { return object : NetworkBoundResource>(appExecutors) { // Here we save the data fetched from web-service. override fun saveCallResult(item: CoinMarketCap) { val list = getCryptocurrencyListFromResponse(fiatCurrencyCode, item.data, item.status?.timestamp) cryptocurrencyDao.reloadCryptocurrencyList(list) myCryptocurrencyDao.reloadMyCryptocurrencyList(list) } // Returns boolean indicating if to fetch data from web or not, true means fetch the data from web. override fun shouldFetch(data: List?): Boolean  shouldFetch  override fun fetchDelayMillis(): Long { return callDelay } // Contains the logic to get data from the Room database. override fun loadFromDb(): LiveData { return Transformations.switchMap(cryptocurrencyDao.getAllCryptocurrencyLiveDataList()) { data -> if (data.isEmpty()) { AbsentLiveData.create() } else { cryptocurrencyDao.getAllCryptocurrencyLiveDataList() } } } // Contains the logic to get data from web-service using Retrofit. override fun createCall(): LiveData>> = api.getAllCryptocurrencies(fiatCurrencyCode) }.asLiveData() } ... fun getCurrentFiatCurrencyCode(): String { return sharedPreferences.getString(context.resources.getString(R.string.pref_fiat_currency_key), context.resources.getString(R.string.pref_default_fiat_currency_value)) ?: context.resources.getString(R.string.pref_default_fiat_currency_value) } ... private fun getCryptocurrencyListFromResponse(fiatCurrencyCode: String, responseList: List?, timestamp: Date?): ArrayList { val cryptocurrencyList: MutableList = ArrayList() responseList?.forEach { val cryptocurrency = Cryptocurrency(it.id, it.name, it.cmcRank.toShort(), it.symbol, fiatCurrencyCode, it.quote.currency.price, it.quote.currency.percentChange1h, it.quote.currency.percentChange7d, it.quote.currency.percentChange24h, timestamp) cryptocurrencyList.add(cryptocurrency) } return cryptocurrencyList as ArrayList } }

Wie Sie im CryptocurrencyRepositoryKlassencode bemerken , verwende ich die NetworkBoundResourceabstrakte Klasse. Was ist das und warum brauchen wir es?

NetworkBoundResourceist eine kleine, aber sehr wichtige Hilfsklasse, mit der wir eine Synchronisation zwischen der lokalen Datenbank und dem Webdienst aufrechterhalten können. Unser Ziel ist es, eine moderne Anwendung zu erstellen, die auch dann reibungslos funktioniert, wenn unser Gerät offline ist. Mit Hilfe dieser Klasse können wir dem Benutzer auch verschiedene Netzwerkzustände wie Fehler oder Laden visuell darstellen.

NetworkBoundResourceBeginnt mit der Beobachtung der Datenbank für die Ressource. Wenn der Eintrag zum ersten Mal aus der Datenbank geladen wird, prüft er, ob das Ergebnis gut genug ist, um versendet zu werden, oder ob es erneut aus dem Netzwerk abgerufen werden soll. Beachten Sie, dass beide Situationen gleichzeitig auftreten können, da Sie wahrscheinlich zwischengespeicherte Daten anzeigen möchten, während Sie sie aus dem Netzwerk aktualisieren.

Wenn der Netzwerkaufruf erfolgreich abgeschlossen wurde, speichert er die Antwort in der Datenbank und initialisiert den Stream neu. Wenn die Netzwerkanforderung fehlschlägt, NetworkBoundResourcesendet der Fehler direkt aus.

/** * A generic class that can provide a resource backed by both the sqlite database and the network. * * * You can read more about it in the [Architecture * Guide](//developer.android.com/arch). * @param  - Type for the Resource data. * @param  - Type for the API response.  */ // It defines two type parameters, ResultType and RequestType, // because the data type returned from the API might not match the data type used locally. abstract class NetworkBoundResource @MainThread constructor(private val appExecutors: AppExecutors) { // The final result LiveData. private val result = MediatorLiveData() init { // Send loading state to UI. result.value = Resource.loading(null) @Suppress("LeakingThis") val dbSource = loadFromDb() result.addSource(dbSource) { data -> result.removeSource(dbSource) if (shouldFetch(data)) { fetchFromNetwork(dbSource) } else { result.addSource(dbSource) { newData -> setValue(Resource.successDb(newData)) } } } } @MainThread private fun setValue(newValue: Resource) { if (result.value != newValue) { result.value = newValue } } // Fetch the data from network and persist into DB and then send it back to UI. private fun fetchFromNetwork(dbSource: LiveData) { val apiResponse = createCall() // We re-attach dbSource as a new source, it will dispatch its latest value quickly. result.addSource(dbSource) { newData -> setValue(Resource.loading(newData)) } // Create inner function as we want to delay it. fun fetch() { result.addSource(apiResponse) { response -> result.removeSource(apiResponse) result.removeSource(dbSource) when (response) { is ApiSuccessResponse -> { appExecutors.diskIO().execute { saveCallResult(processResponse(response)) appExecutors.mainThread().execute { // We specially request a new live data, // otherwise we will get immediately last cached value, // which may not be updated with latest results received from network. result.addSource(loadFromDb()) { newData -> setValue(Resource.successNetwork(newData)) } } } } is ApiEmptyResponse -> { appExecutors.mainThread().execute { // reload from disk whatever we had result.addSource(loadFromDb()) { newData -> setValue(Resource.successDb(newData)) } } } is ApiErrorResponse -> { onFetchFailed() result.addSource(dbSource) { newData -> setValue(Resource.error(response.errorMessage, newData)) } } } } } // Add delay before call if needed. val delay = fetchDelayMillis() if (delay > 0) { Handler().postDelayed({ fetch() }, delay) } else fetch() } // Called when the fetch fails. The child class may want to reset components // like rate limiter. protected open fun onFetchFailed() {} // Returns a LiveData object that represents the resource that's implemented // in the base class. fun asLiveData() = result as LiveData @WorkerThread protected open fun processResponse(response: ApiSuccessResponse) = response.body // Called to save the result of the API response into the database. @WorkerThread protected abstract fun saveCallResult(item: RequestType) // Called with the data in the database to decide whether to fetch // potentially updated data from the network. @MainThread protected abstract fun shouldFetch(data: ResultType?): Boolean // Make a call to the server after some delay for better user experience. protected open fun fetchDelayMillis(): Long = 0 // Called to get the cached data from the database. @MainThread protected abstract fun loadFromDb(): LiveData // Called to create the API call. @MainThread protected abstract fun createCall(): LiveData }

Unter der Haube wird die NetworkBoundResourceKlasse mithilfe von MediatorLiveData und seiner Fähigkeit erstellt, mehrere LiveData-Quellen gleichzeitig zu beobachten. Hier haben wir zwei LiveData-Quellen: die Datenbank und die Antwort auf Netzwerkanrufe. Diese beiden LiveData werden in eine MediatorLiveData eingeschlossen, die von verfügbar gemacht wird NetworkBoundResource.

Schauen wir uns genauer an, wie das NetworkBoundResourcein unserer App funktioniert. Stellen Sie sich vor, der Benutzer startet die App und klickt auf eine schwebende Aktionsschaltfläche in der unteren rechten Ecke. Die App startet den Bildschirm zum Hinzufügen von Kryptomünzen. Jetzt können wir die Verwendung darin analysieren NetworkBoundResource.

Wenn die App neu installiert wurde und zum ersten Mal gestartet wird, werden keine Daten in der lokalen Datenbank gespeichert. Da keine Daten angezeigt werden können, wird eine Benutzeroberfläche für den Ladefortschrittsbalken angezeigt. In der Zwischenzeit wird die App über einen Webdienst einen Anforderungsaufruf an den Server senden, um alle Kryptowährungslisten abzurufen.

Wenn die Antwort nicht erfolgreich ist, wird die Benutzeroberfläche für die Fehlermeldung mit der Möglichkeit angezeigt, einen Anruf per Knopfdruck erneut zu versuchen. Wenn ein Anforderungsaufruf endlich erfolgreich ist, werden die Antwortdaten in einer lokalen SQLite-Datenbank gespeichert.

Wenn wir das nächste Mal zum selben Bildschirm zurückkehren, lädt die App Daten aus der Datenbank, anstatt erneut ins Internet zu telefonieren. Der Benutzer kann jedoch eine neue Datenaktualisierung anfordern, indem er die Pull-to-Refresh-Funktion implementiert. Alte Dateninformationen werden angezeigt, während der Netzwerkanruf ausgeführt wird. All dies geschieht mit Hilfe von NetworkBoundResource.

Eine andere Klasse, die in unserem Repository verwendet wird und LiveDataCallAdapterin der all die "Magie" passiert, ist ApiResponse. Eigentlich ApiResponseist es nur ein einfacher allgemeiner Wrapper um die Retrofit2.ResponseKlasse, der jede Antwort in eine Instanz von LiveData konvertiert.

/** * Common class used by API responses. ApiResponse is a simple wrapper around the Retrofit2.Call * class that convert responses to instances of LiveData. * @param  the type of the response object  */ @Suppress("unused") // T is used in extending classes sealed class ApiResponse { companion object { fun  create(error: Throwable): ApiErrorResponse { return ApiErrorResponse(error.message ?: "Unknown error.") } fun  create(response: Response): ApiResponse { return if (response.isSuccessful) { val body = response.body() if (body == null || response.code() == 204) { ApiEmptyResponse() } else { ApiSuccessResponse(body = body) } } else { // Convert error response to JSON object. val gson = Gson() val type = object : TypeToken() {}.type val errorResponse: CoinMarketCap = gson.fromJson(response.errorBody()!!.charStream(), type) val msg = errorResponse.status?.errorMessage ?: errorResponse.message val errorMsg = if (msg.isNullOrEmpty()) { response.message() } else { msg } ApiErrorResponse(errorMsg ?: "Unknown error.") } } } } /** * Separate class for HTTP 204 resposes so that we can make ApiSuccessResponse's body non-null. */ class ApiEmptyResponse : ApiResponse() data class ApiSuccessResponse(val body: CoinMarketCapType) : ApiResponse() data class ApiErrorResponse(val errorMessage: String) : ApiResponse()

Wenn in unserer Wrapper-Klasse unsere Antwort einen Fehler enthält, verwenden wir die Gson-Bibliothek, um den Fehler in ein JSON-Objekt zu konvertieren. Wenn die Antwort jedoch erfolgreich war, wird der Gson-Konverter für die Zuordnung von JSON zu POJO-Objekten verwendet. Wir haben es bereits , wenn sie mit der Nachrüst - Builder Instanz GsonConverterFactoryinnerhalb der Dagger AppModuleFunktion provideApiService.

Gleiten Sie zum Laden von Bildern

Was ist Gleiten? Aus den Dokumenten:

Glide ist ein schnelles und effizientes Open-Source-Framework für die Medienverwaltung und das Laden von Bildern für Android, das Mediendecodierung, Speicher- und Festplatten-Caching sowie Ressourcenpooling in einer einfachen und benutzerfreundlichen Oberfläche zusammenfasst von Bildern so flüssig und schnell wie möglich, aber es ist auch in fast allen Fällen effektiv, in denen Sie ein Remote-Bild abrufen, in der Größe ändern und anzeigen müssen.

Klingt nach einer komplizierten Bibliothek, die viele nützliche Funktionen bietet, die Sie nicht alleine entwickeln möchten. In der My Crypto Coins-App gibt es mehrere Listenbildschirme, auf denen mehrere Kryptowährungslogos angezeigt werden müssen - Bilder, die gleichzeitig aus dem Internet aufgenommen wurden - und dennoch ein reibungsloses Scrollen für den Benutzer gewährleisten müssen. Diese Bibliothek passt also perfekt zu unseren Bedürfnissen. Auch diese Bibliothek ist bei Android-Entwicklern sehr beliebt.

Schritte zum Einrichten des App-Projekts Glide on My Crypto Coins:

Abhängigkeiten deklarieren

Holen Sie sich die neueste Glide-Version. Wieder ist Versionen eine separate Datei versions.gradlefür das Projekt.

// Glide implementation "com.github.bumptech.glide:glide:$versions.glide" kapt "com.github.bumptech.glide:compiler:$versions.glide" // Glide's OkHttp3 integration. implementation "com.github.bumptech.glide:okhttp3-integration:$versions.glide"+"@aar"

Da wir die Netzwerkbibliothek OkHttp in unserem Projekt für alle Netzwerkoperationen verwenden möchten, müssen wir anstelle der Standardintegration die spezifische Glide-Integration dafür einbeziehen. Da Glide eine Netzwerkanforderung zum Laden von Bildern über das Internet ausführen wird, müssen wir die Berechtigung INTERNETin unsere AndroidManifest.xmlDatei aufnehmen - dies haben wir jedoch bereits mit dem Retrofit-Setup getan.

Erstellen Sie AppGlideModule

Glide v4, das wir verwenden werden, bietet eine generierte API für Anwendungen. Es wird ein Annotationsprozessor verwendet, um eine API zu generieren, mit der Anwendungen die Glide-API erweitern und Komponenten einschließen können, die von Integrationsbibliotheken bereitgestellt werden. Damit eine App auf die generierte Glide-API zugreifen kann, muss eine entsprechend kommentierte AppGlideModuleImplementierung enthalten sein. Es kann nur eine einzige Implementierung der generierten API und nur eine AppGlideModulepro Anwendung geben.

Erstellen wir eine Klasse, die sich AppGlideModuleirgendwo in Ihrem App-Projekt erstreckt:

/** * Glide v4 uses an annotation processor to generate an API that allows applications to access all * options in RequestBuilder, RequestOptions and any included integration libraries in a single * fluent API. * * The generated API serves two purposes: * Integration libraries can extend Glide’s API with custom options. * Applications can extend Glide’s API by adding methods that bundle commonly used options. * * Although both of these tasks can be accomplished by hand by writing custom subclasses of * RequestOptions, doing so is challenging and produces a less fluent API. */ @GlideModule class AppGlideModule : AppGlideModule()

Auch wenn unsere Anwendung keine zusätzlichen Einstellungen ändert oder keine Methoden implementiert AppGlideModule, muss die Implementierung für die Verwendung von Glide erforderlich sein. Sie müssen keine der Methoden implementieren, AppGlideModuledamit die API generiert wird. Sie können die Klasse leer lassen, solange sie erweitert AppGlideModuleund mit Anmerkungen versehen ist @GlideModule.

Verwenden Sie die von Glide generierte API

Bei Verwendung AppGlideModulekönnen Anwendungen die API verwenden, indem sie alle Ladevorgänge mit starten GlideApp.with(). Dies ist der Code, der zeigt, wie ich Glide zum Laden und Anzeigen von Kryptowährungslogos in der Liste zum Hinzufügen aller Kryptowährungen verwendet habe.

class AddSearchListAdapter(val context: Context, private val cryptocurrencyClickCallback: ((Cryptocurrency) -> Unit)?) : BaseAdapter() { ... override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { ... val itemBinding: ActivityAddSearchListItemBinding ... // We make an Uri of image that we need to load. Every image unique name is its id. val imageUri = Uri.parse(CRYPTOCURRENCY_IMAGE_URL).buildUpon() .appendPath(CRYPTOCURRENCY_IMAGE_SIZE_PX) .appendPath(cryptocurrency.id.toString() + CRYPTOCURRENCY_IMAGE_FILE) .build() // Glide generated API from AppGlideModule. GlideApp // We need to provide context to make a call. .with(itemBinding.root) // Here you specify which image should be loaded by providing Uri. .load(imageUri) // The way you combine and execute multiple transformations. // WhiteBackground is our own implemented custom transformation. // CircleCrop is default transformation that Glide ships with. .transform(MultiTransformation(WhiteBackground(), CircleCrop())) // The target ImageView your image is supposed to get displayed in. .into(itemBinding.itemImageIcon.imageview_front) ... return itemBinding.root } ... }

Wie Sie sehen, können Sie Glide mit nur wenigen Codezeilen verwenden und es die ganze harte Arbeit für Sie erledigen lassen. Es ist ziemlich einfach.

Kotlin Coroutines

Während der Erstellung dieser App werden wir mit Situationen konfrontiert sein, in denen wir zeitaufwändige Aufgaben ausführen, z. B. Daten in eine Datenbank schreiben oder daraus lesen, Daten aus dem Netzwerk abrufen und andere. All diese allgemeinen Aufgaben dauern länger als im Hauptthread des Android-Frameworks zulässig.

Der Hauptthread ist ein einzelner Thread, der alle Aktualisierungen der Benutzeroberfläche verarbeitet. Entwickler müssen es nicht blockieren, um zu verhindern, dass die App einfriert oder sogar mit einem Dialogfeld "Anwendung reagiert nicht" abstürzt. Kotlin Coroutines wird dieses Problem für uns lösen, indem es die Sicherheit des Hauptgewindes einführt. Es ist das letzte fehlende Teil, das wir für die My Crypto Coins-App hinzufügen möchten.

Coroutinen sind eine Kotlin-Funktion, die asynchrone Rückrufe für lang laufende Aufgaben wie Datenbank- oder Netzwerkzugriff in sequentiellen Code konvertiert. Mit Coroutinen können Sie asynchronen Code, der traditionell mit dem Callback-Muster geschrieben wurde, in einem synchronen Stil schreiben. Der Rückgabewert einer Funktion liefert das Ergebnis des asynchronen Aufrufs. Nacheinander geschriebener Code ist in der Regel einfacher zu lesen und kann sogar Sprachfunktionen wie Ausnahmen verwenden.

Daher werden wir in dieser App überall Coroutinen verwenden, wo wir warten müssen, bis ein Ergebnis einer lang laufenden Aufgabe verfügbar ist, und dann die Ausführung fortsetzen. Sehen wir uns eine genaue Implementierung für unser ViewModel an, bei der wir erneut versuchen, die neuesten Daten für unsere auf dem Hauptbildschirm angezeigten Kryptowährungen vom Server abzurufen.

Fügen Sie dem Projekt zunächst Coroutinen hinzu:

// Coroutines support libraries for Kotlin. // Dependencies for coroutines. implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines" // Dependency is for the special UI context that can be passed to coroutine builders that use // the main thread dispatcher to dispatch events on the main thread. implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines"

Anschließend erstellen wir eine abstrakte Klasse, die zur Basisklasse für jedes ViewModel wird, das über gemeinsame Funktionen wie in unserem Fall Coroutinen verfügen muss:

abstract class BaseViewModel : ViewModel() { // In Kotlin, all coroutines run inside a CoroutineScope. // A scope controls the lifetime of coroutines through its job. private val viewModelJob = Job() // Since uiScope has a default dispatcher of Dispatchers.Main, this coroutine will be launched // in the main thread. val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) // onCleared is called when the ViewModel is no longer used and will be destroyed. // This typically happens when the user navigates away from the Activity or Fragment that was // using the ViewModel. override fun onCleared() { super.onCleared() // When you cancel the job of a scope, it cancels all coroutines started in that scope. // It's important to cancel any coroutines that are no longer required to avoid unnecessary // work and memory leaks. viewModelJob.cancel() } }

Hier erstellen wir einen spezifischen Coroutine-Bereich, der die Lebensdauer von Coroutines durch seine Arbeit steuert. Wie Sie sehen, können Sie mit scope einen Standard-Dispatcher angeben, der steuert, auf welchem ​​Thread eine Coroutine ausgeführt wird. Wenn das ViewModel nicht mehr verwendet wird, brechen wir ab viewModelJobund damit wird auch jede Coroutine, die von gestartet wurde uiScope, abgebrochen.

Implementieren Sie abschließend die Wiederholungsfunktion:

/** * The ViewModel class is designed to store and manage UI-related data in a lifecycle conscious way. * The ViewModel class allows data to survive configuration changes such as screen rotations. */ // ViewModel will require a CryptocurrencyRepository so we add @Inject code into ViewModel constructor. class MainViewModel @Inject constructor(val context: Context, val cryptocurrencyRepository: CryptocurrencyRepository) : BaseViewModel() { ... val mediatorLiveDataMyCryptocurrencyResourceList = MediatorLiveData>() private var liveDataMyCryptocurrencyResourceList: LiveData> private val liveDataMyCryptocurrencyList: LiveData ... // This is additional helper variable to deal correctly with currency spinner and preference. // It is kept inside viewmodel not to be lost because of fragment/activity recreation. var newSelectedFiatCurrencyCode: String? = null // Helper variable to store state of swipe refresh layout. var isSwipeRefreshing: Boolean = false init { ... // Set a resource value for a list of cryptocurrencies that user owns. liveDataMyCryptocurrencyResourceList = cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList(cryptocurrencyRepository.getCurrentFiatCurrencyCode()) // Declare additional variable to be able to reload data on demand. mediatorLiveDataMyCryptocurrencyResourceList.addSource(liveDataMyCryptocurrencyResourceList) { mediatorLiveDataMyCryptocurrencyResourceList.value = it } ... } ... /** * On retry we need to run sequential code. First we need to get owned crypto coins ids from * local database, wait for response and only after it use these ids to make a call with * retrofit to get updated owned crypto values. This can be done using Kotlin Coroutines. */ fun retry(newFiatCurrencyCode: String? = null) { // Here we store new selected currency as additional variable or reset it. // Later if call to server is unsuccessful we will reuse it for retry functionality. newSelectedFiatCurrencyCode = newFiatCurrencyCode // Launch a coroutine in uiScope. uiScope.launch { // Make a call to the server after some delay for better user experience. updateMyCryptocurrencyList(newFiatCurrencyCode, SERVER_CALL_DELAY_MILLISECONDS) } } // Refresh the data from local database. fun refreshMyCryptocurrencyResourceList() { refreshMyCryptocurrencyResourceList(cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList(cryptocurrencyRepository.getCurrentFiatCurrencyCode())) } // To implement a manual refresh without modifying your existing LiveData logic. private fun refreshMyCryptocurrencyResourceList(liveData: LiveData>) { mediatorLiveDataMyCryptocurrencyResourceList.removeSource(liveDataMyCryptocurrencyResourceList) liveDataMyCryptocurrencyResourceList = liveData mediatorLiveDataMyCryptocurrencyResourceList.addSource(liveDataMyCryptocurrencyResourceList) { mediatorLiveDataMyCryptocurrencyResourceList.value = it } } private suspend fun updateMyCryptocurrencyList(newFiatCurrencyCode: String? = null, callDelay: Long = 0) { val fiatCurrencyCode: String = newFiatCurrencyCode ?: cryptocurrencyRepository.getCurrentFiatCurrencyCode() isSwipeRefreshing = true // The function withContext is a suspend function. The withContext immediately shifts // execution of the block into different thread inside the block, and back when it // completes. IO dispatcher is suitable for execution the network requests in IO thread. val myCryptocurrencyIds = withContext(Dispatchers.IO) { // Suspend until getMyCryptocurrencyIds() returns a result. cryptocurrencyRepository.getMyCryptocurrencyIds() } // Here we come back to main worker thread. As soon as myCryptocurrencyIds has a result // and main looper is available, coroutine resumes on main thread, and // [getMyCryptocurrencyLiveDataResourceList] is called. // We wait for background operations to complete, without blocking the original thread. refreshMyCryptocurrencyResourceList( cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList (fiatCurrencyCode, true, myCryptocurrencyIds, callDelay)) } ... }

Hier rufen wir eine Funktion auf, die mit einem speziellen Kotlin-Schlüsselwort suspendfür Coroutinen gekennzeichnet ist. Dies bedeutet, dass die Funktion die Ausführung unterbricht, bis das Ergebnis fertig ist, und dann dort fortgesetzt wird, wo sie mit dem Ergebnis aufgehört hat. Während es auf ein Ergebnis wartet, wird der Thread, auf dem es ausgeführt wird, entsperrt.

In einer Suspend-Funktion können wir auch eine andere Suspend-Funktion aufrufen. Wie Sie sehen, rufen wir dazu die markierte neue Suspend-Funktion auf withContext, die auf einem anderen Thread ausgeführt wird.

Die Idee dieses ganzen Codes ist, dass wir mehrere Aufrufe kombinieren können, um einen gut aussehenden sequentiellen Code zu bilden. Zuerst fordern wir an, die IDs der Kryptowährungen, die wir besitzen, aus der lokalen Datenbank abzurufen und auf die Antwort zu warten. Erst nachdem wir es erhalten haben, verwenden wir die Antwort-IDs, um einen neuen Anruf mit Retrofit zu tätigen, um diese aktualisierten Kryptowährungswerte abzurufen. Das ist unsere Wiederholungsfunktion.

Wir haben es geschafft! Letzte Gedanken, Repository, App & Präsentation

Herzlichen Glückwunsch, ich freue mich, wenn Sie es geschafft haben, bis zum Ende zu gelangen. Alle wichtigen Punkte für die Erstellung dieser App wurden behandelt. In diesem Teil wurden viele neue Dinge getan, und viele davon werden in diesem Artikel nicht behandelt, aber ich habe meinen Code überall sehr gut kommentiert, damit Sie sich nicht darin verlieren. Schauen Sie sich den endgültigen Code für diesen Teil 5 hier auf GitHub an:

Quelle auf GitHub anzeigen.

Die größte Herausforderung für mich persönlich bestand nicht darin, neue Technologien zu erlernen, die App nicht zu entwickeln, sondern all diese Artikel zu schreiben. Eigentlich bin ich sehr glücklich mit mir, dass ich diese Herausforderung gemeistert habe. Lernen und Entwickeln ist im Vergleich zum Lehren anderer einfach, aber hier können Sie das Thema noch besser verstehen. Mein Rat, wenn Sie nach dem besten Weg suchen, neue Dinge zu lernen, ist, sofort selbst etwas zu kreieren. Ich verspreche Ihnen, dass Sie viel und schnell lernen werden.

Alle diese Artikel basieren auf der Version 1.0.0 der App „Kriptofolio“ (früher „My Crypto Coins“), die Sie hier als separate APK-Datei herunterladen können. Aber ich würde mich sehr freuen, wenn Sie die neueste App-Version direkt aus dem Store installieren und bewerten:

Bekomm es auf Google Play

Besuchen Sie auch diese einfache Präsentationswebsite, die ich für dieses Projekt erstellt habe:

Kriptofolio.app

Ačiū! Danke fürs Lesen! Ich habe diesen Beitrag ursprünglich für meinen persönlichen Blog www.baruckis.com am 11. Mai 2019 veröffentlicht.