Как да се справяте с RESTful уеб услуги, използвайки Retrofit, OkHttp, Gson, Glide и Coroutines

Криптофолио серия от приложения - Част 5

В наши дни почти всяко приложение за Android се свързва с интернет, за да получава / изпраща данни. Определено трябва да се научите как да боравите с RESTful Web Services, тъй като правилното им изпълнение е основното знание при създаването на модерни приложения.

Тази част ще бъде сложна. Ще комбинираме няколко библиотеки наведнъж, за да получим работен резултат. Няма да говоря за родния Android начин за обработка на интернет заявки, защото в реалния свят никой не го използва. Всяко добро приложение не се опитва да преоткрие колелото, а вместо това използва най-популярните библиотеки на трети страни за решаване на често срещани проблеми. Би било твърде сложно да се пресъздаде функционалността, която предлагат тези добре изработени библиотеки.

Съдържание на поредицата

  • Въведение: Пътна карта за изграждане на модерно приложение за Android през 2018–2019 г.
  • Част 1: Въведение в принципите SOLID
  • Част 2: Как да започнете да изграждате вашето приложение за Android: създаване на макети, потребителски интерфейс и XML оформления
  • Част 3: Всичко за тази архитектура: изследване на различни архитектурни модели и как да ги използвате в приложението си
  • Част 4: Как да внедрите Dependency Injection във вашето приложение с Dagger 2
  • Част 5: Работете с RESTful уеб услуги с помощта на модернизация, OkHttp, Gson, Glide и Coroutines (вие сте тук)

Какво е модернизация, OkHttp и Gson?

Модернизацията е REST клиент за Java и Android. Според мен тази библиотека е най-важната за научаване, тъй като ще свърши основната работа. Улеснява извличането и качването на JSON (или други структурирани данни) чрез базирана на REST уеб услуга.

В Retrofit вие конфигурирате кой конвертор да се използва за сериализация на данни. Обикновено за сериализиране и десериализиране на обекти към и от JSON използвате Java библиотека с отворен код - Gson. Също така, ако имате нужда, можете да добавите персонализирани конвертори към Retrofit за обработка на XML или други протоколи.

За извършване на HTTP заявки Retrofit използва библиотеката OkHttp. OkHttp е чист HTTP / SPDY клиент, отговорен за всякакви мрежови операции на ниско ниво, кеширане, манипулиране на заявки и отговори. За разлика от тях, Retrofit е REST абстракция на високо ниво, изградена върху OkHttp. Модернизацията е тясно свързана с OkHttp и го използва интензивно.

Сега, след като знаете, че всичко е тясно свързано, ще използваме всички тези 3 библиотеки наведнъж. Първата ни цел е да получим целия списък с криптовалути, използвайки Retrofit от Интернет. Ще използваме специален клас прихващач OkHttp за удостоверяване на API на CoinMarketCap, когато правим повикване към сървъра. Ще получим резултат от JSON данни и след това ще го преобразуваме с помощта на библиотеката Gson.

Бърза настройка за Retrofit 2 само за да опитате първо

Когато научавам нещо ново, обичам да го изпробвам на практика възможно най-скоро. Ще приложим подобен подход с Retrofit 2, за да го разберете по-бързо по-бързо. Не се притеснявайте точно сега за качеството на кода или каквито и да е принципи на програмиране или оптимизации - ние просто ще напишем малко код, за да накараме Retrofit 2 да работи в нашия проект и ще обсъдим какво прави.

Следвайте тези стъпки, за да настроите Retrofit 2 в проекта за приложението My Crypto Coins:

Първо, дайте разрешение за ИНТЕРНЕТ за приложението

Ще изпълним HTTP заявки на сървър, достъпен през Интернет. Дайте това разрешение, като добавите следните редове към вашия файл с манифест:

  ... 

След това трябва да добавите библиотечни зависимости

Намерете най-новата версия за модернизация. Също така трябва да знаете, че модернизацията не се доставя с интегриран JSON конвертор. Тъй като ще получим отговори във формат JSON, трябва да включим и конвертора ръчно в зависимостите. Ще използваме най-новата версия на Gson на JSON конвертор на Google. Нека добавим тези редове към вашия gradle файл:

// 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"

Както забелязахте от моя коментар, зависимостта OkHttp вече се доставя със зависимостта Retrofit 2. Versions е просто отделен файл gradle за удобство:

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

След това настройте интерфейса за модернизация

Това е интерфейс, който декларира нашите заявки и техните типове. Тук дефинираме API от страна на клиента.

/** * 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. }

И настройте класа на данните

Класовете данни са POJO (Plain Old Java Objects), които представляват отговорите на API извикванията, които ще направим.

/** * 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 ) }

Създайте специален клас на прехващач за удостоверяване при извършване на повикване към сървъра

Това е конкретният случай за всеки API, който изисква удостоверяване, за да получи успешен отговор. Прихващачите са мощен начин за персонализиране на вашите заявки. Ще прихванем действителната заявка и ще добавим отделни заглавки на заявки, които ще потвърдят повикването с API ключ, предоставен от CoinMarketCap Professional API Developer Portal. За да получите своя, трябва да се регистрирате там.

/** * 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) } }

И накрая, добавете този код към нашата дейност, за да видите как работи модернизацията

Исках да ти изцапа ръцете възможно най-скоро, затова сложих всичко на едно място. Това не е правилният начин, но вместо това е най-бързият, само за да видите бързо визуален резултат.

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

Можете да разгледате кода тук. Не забравяйте, че това е само първоначална опростена версия за внедряване, за да получите идеята по-добра.

Окончателна правилна настройка за Retrofit 2 с OkHttp 3 и Gson

Добре след бърз експеримент, време е да внесем тази модернизация на следващото ниво. Вече получихме данните успешно, но не правилно. Липсват ни състоянията като зареждане, грешка и успех. Нашият код е смесен, без отделяне на опасенията. Често срещана грешка е да напишете целия си код в дейност или фрагмент. Нашият клас на дейност е базиран на потребителски интерфейс и трябва да съдържа само логика, която обработва потребителския интерфейс и взаимодействията на операционната система.

Всъщност след тази бърза настройка работих много и направих много промени. Няма смисъл да поставяте целия код, който е променен в статията. По-добре вместо това трябва да разгледате окончателния репо-код на част 5 тук. Коментирах всичко много добре и кодът ми трябва да е ясен, за да го разберете. Но ще говоря за най-важните неща, които съм направил, и защо ги направих.

Първата стъпка за подобряване беше да започнете да използвате инжекция за зависимост. Не забравяйте от предишната част, че вече имаме правилно внедрен Dagger 2 в проекта. Затова го използвах за настройката на модернизацията.

/** * 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. } ... }

Както виждате, модернизацията е отделена от класа на активността, както трябва да бъде. Той ще бъде инициализиран само веднъж и използван за цялото приложение.

Както може би сте забелязали при създаването на екземпляр на Retrofit builder, ние добавихме специален адаптер за повиквания за модернизация, използвайки addCallAdapterFactory. По подразбиране Retrofit връща a Call, но за нашия проект ние изискваме да върне LiveDataтип. За да направим това, трябва да добавим LiveDataCallAdapterс помощта на 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) } }

Сега ще получим LiveDataвместо Callкато тип на връщане от методите за модернизиране на услуги, дефинирани в ApiServiceинтерфейса.

Друга важна стъпка е да започнете да използвате шаблона на хранилището. Говорих за това в Част 3. Разгледайте нашата схема за архитектура на MVVM от тази публикация, за да запомните къде отива.

Както виждате на снимката, хранилището е отделен слой за данните. Това е нашият единствен източник за контакт за получаване или изпращане на данни. Когато използваме хранилището, ние следваме принципа на разделяне на проблемите. Можем да имаме различни източници на данни (като в нашия случай постоянни данни от база данни на SQLite и данни от уеб услуги), но хранилището винаги ще бъде единствен източник на истина за всички данни на приложението.

Вместо да комуникираме директно с нашата реализация на Retrofit, ще използваме 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 } }

Както забелязвате в CryptocurrencyRepositoryкода на класа, аз използвам NetworkBoundResourceабстрактния клас. Какво е това и защо се нуждаем от него?

NetworkBoundResourceе малък, но много важен помощен клас, който ще ни позволи да поддържаме синхронизация между локалната база данни и уеб услугата. Нашата цел е да създадем модерно приложение, което да работи безпроблемно дори когато устройството ни е офлайн. Също така с помощта на този клас ще можем визуално да представяме различни състояния на мрежата като грешки или зареждане за потребителя.

NetworkBoundResourceзапочва с наблюдение на базата данни за ресурса. Когато записът се зарежда от базата данни за първи път, той проверява дали резултатът е достатъчно добър, за да бъде изпратен, или трябва да бъде извлечен отново от мрежата. Имайте предвид, че и двете ситуации могат да се случат едновременно, като се има предвид, че вероятно искате да покажете кеширани данни, докато ги актуализирате от мрежата.

Ако мрежовото повикване завърши успешно, то запазва отговора в базата данни и повторно инициализира потока. Ако мрежовата заявка не успее, NetworkBoundResourceизпраща директно грешка.

/** * 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 }

Под капака NetworkBoundResourceкласът е направен с помощта на MediatorLiveData и способността му да наблюдава множество източници LiveData наведнъж. Тук имаме два източника на LiveData: базата данни и отговорът на мрежовото повикване. И двете от тези LiveData са обвити в един MediatorLiveData, който е изложен от NetworkBoundResource.

Нека разгледаме по-отблизо как NetworkBoundResourceще работи приложението в нашето приложение. Представете си, че потребителят ще стартира приложението и щракне върху плаващ бутон за действие в долния десен ъгъл. Приложението ще стартира екрана за добавяне на крипто монети. Сега можем да анализираме NetworkBoundResourceупотребата в него.

Ако приложението е прясно инсталирано и това е първото му стартиране, в локалната база данни няма да се съхраняват данни. Тъй като няма данни за показване, ще се покаже потребителски интерфейс на лентата за хода на зареждане. Междувременно приложението ще направи заявка за обаждане до сървъра чрез уеб услуга, за да получи целия списък с криптовалути.

Ако отговорът е неуспешен, тогава потребителският интерфейс за съобщение за грешка ще се покаже с възможност за повторен опит за повикване чрез натискане на бутон. Когато извикването на заявка най-накрая е успешно, тогава данните за отговора ще бъдат записани в локална база данни на SQLite.

Ако се върнем към същия екран следващия път, приложението ще зареди данни от базата данни, вместо да се обажда отново в интернет. Но потребителят може да поиска нова актуализация на данните чрез внедряване на функцията за изтегляне за опресняване. По време на мрежовото повикване ще се показва стара информация за данни. Всичко това се прави с помощта на NetworkBoundResource.

Друг клас, използван в нашето хранилище и LiveDataCallAdapterкъдето се случва цялата „магия“, е ApiResponse. Всъщност ApiResponseе просто обикновена обвивка около Retrofit2.Responseкласа, която преобразува всеки отговор в екземпляр на LiveData.

/** * 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()

Вътре в този клас на обвивка, ако отговорът ни има грешка, използваме библиотеката Gson, за да преобразуваме грешката в JSON обект. Ако обаче отговорът е бил успешен, тогава се използва преобразувателят на Gson за JSON към POJO обект. Вече го добавихме при създаването на екземпляр на модернизиращия конструктор с функция GsonConverterFactoryDagger .AppModuleprovideApiService

Плъзнете за зареждане на изображението

Какво е Glide? От документите:

Glide е бърза и ефективна рамка за управление на медии с отворен код и зареждане на изображения за Android, която обхваща декодиране на медии, кеширане на памет и дискове и обединяване на ресурси в прост и лесен за използване интерфейс. Основният фокус на Glide е върху превъртането на всякакъв вид списък на изображения възможно най-гладко и бързо, но е ефективно и за почти всеки случай, когато трябва да изтеглите, преоразмерите и покажете отдалечено изображение.

Звучи като сложна библиотека, която предлага много полезни функции, които не бихте искали да разработите сами. В приложението My Crypto Coins имаме няколко екрана със списъци, на които трябва да покажем множество лога на криптовалута - снимки, направени от интернет наведнъж - и все пак да осигурим безпроблемно превъртане за потребителя. Така че тази библиотека напълно отговаря на нашите нужди. Също така тази библиотека е много популярна сред разработчиците на Android.

Стъпки за настройка на Glide в проекта за приложението My Crypto Coins:

Декларирайте зависимости

Вземете най-новата версия на Glide. Отново версиите са отделен файл versions.gradleза проекта.

// 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"

Тъй като искаме да използваме мрежовата библиотека OkHttp в нашия проект за всички мрежови операции, трябва да включим конкретната интеграция на Glide вместо нея по подразбиране. Освен това, тъй като Glide ще изпълни мрежова заявка за зареждане на изображения през интернет, трябва да включим разрешението INTERNETв нашия AndroidManifest.xmlфайл - но вече го направихме с настройката за модернизация.

Създайте AppGlideModule

Glide v4, който ще използваме, предлага генериран API за приложения. Той ще използва процесор за анотиране, за да генерира API, който позволява на приложенията да разширяват API на Glide и да включват компоненти, предоставени от интеграционни библиотеки. За да може всяко приложение да има достъп до генерирания Glide API, трябва да включим подходящо коментирано AppGlideModuleизпълнение. Може да има само една реализация на генерирания API и само по едно AppGlideModuleза приложение.

Нека създадем клас, разширяващ се AppGlideModuleнякъде във вашия проект за приложение:

/** * 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()

Дори ако нашето приложение не променя никакви допълнителни настройки или не прилага каквито и да било методи AppGlideModule, все пак трябва да имаме неговото изпълнение, за да използваме Glide. Не се изисква да прилагате нито един от методите, AppGlideModuleза да може API да бъде генериран. Можете да оставите класа празен, стига да се разширява AppGlideModuleи да е коментиран с @GlideModule.

Използвайте API, генериран от Glide

При използване AppGlideModuleприложенията могат да използват API, като стартират всички зареждания с GlideApp.with(). Това е кодът, който показва как съм използвал Glide за зареждане и показване на лога на криптовалута в екрана за добавяне на крипто монети на всички списъци с криптовалути.

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

Както виждате, можете да започнете да използвате Glide само с няколко реда код и да го оставите да върши цялата упорита работа вместо вас. Това е доста лесно.

Котлин Корутини

Докато изграждаме това приложение, ще се сблъскаме със ситуации, когато ще изпълняваме отнемащи време задачи като запис на данни в база данни или четене от нея, извличане на данни от мрежата и други. Всички тези често срещани задачи отнемат повече време, отколкото е позволено от основната нишка на Android framework.

Основната нишка е единична нишка, която обработва всички актуализации на потребителския интерфейс. От разработчиците се изисква да не го блокират, за да се избегне замразяване на приложението или дори срив с диалогов прозорец Приложение, което не отговаря. Програмите на Kotlin ще решат този проблем за нас чрез въвеждане на безопасност на основните нишки. Това е последното липсващо парче, което искаме да добавим за приложението My Crypto Coins.

Програмите са функция на Kotlin, която преобразува асинхронни обратни извиквания за дълго изпълняващи се задачи, като достъп до база данни или мрежа, в последователен код. С подпрограмите можете да пишете асинхронен код, който традиционно се пише с помощта на обратния обратен извикване, използвайки синхронен стил. Връщаната стойност на функция ще предостави резултата от асинхронното повикване. Кодът, написан последователно, обикновено е по-лесен за четене и дори може да използва езикови функции като изключения.

Така че ще използваме съпрограми навсякъде в това приложение, където трябва да изчакаме, докато се получи резултат от дългосрочна задача и след това да продължим изпълнението. Нека видим една точна реализация за нашия ViewModel, където ще опитаме отново да получим най-новите данни от сървъра за нашите криптовалути, представени на главния екран.

Първо добавете съпрограми към проекта:

// 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"

След това ще създадем абстрактен клас, който ще се превърне в базовия клас, който ще се използва за всеки ViewModel, който трябва да има обща функционалност като съпрограми в нашия случай:

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

Тук ние създаваме специфичен обхват, който ще контролира живота на съпрограмите чрез своята работа. Както виждате, обхватът ви позволява да посочите диспечер по подразбиране, който контролира коя нишка изпълнява съпрограма. Когато ViewModel вече не се използва, ние анулираме viewModelJobи с това uiScopeще бъде анулирана и всяка съпрограма, стартирана от .

И накрая, внедрете функционалността за повторен опит:

/** * 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)) } ... }

Тук извикваме функция, маркирана със специална ключова дума Kotlin suspendза съпрограми. Това означава, че функцията спира изпълнението, докато резултатът не е готов, след което възобновява мястото, където е спряла с резултата. Докато е спряно в очакване на резултат, то отблокира нишката, върху която работи.

Също така, в една функция за спиране можем да извикаме друга функция за спиране. Както виждате, правим това, като извикаме новата функция за спиране, маркирана, withContextкоято се изпълнява в различна нишка.

Идеята на целия този код е, че можем да комбинираме множество повиквания, за да образуваме добре изглеждащ последователен код. Първо искаме да получим идентификаторите на криптовалутите, които притежаваме, от локалната база данни и изчакваме отговора. Едва след като го получим, използваме идентификационните номера за отговор, за да осъществим ново обаждане с Retrofit, за да получим тези актуализирани стойности на криптовалута. Това е нашата функционалност за повторен опит.

Направихме го! Финални мисли, хранилище, приложение и презентация

Поздравления, радвам се, ако успяхте да стигнете до края. Покрити са всички най-важни моменти за създаването на това приложение. В тази част бяха направени много нови неща и много от тях не са обхванати от тази статия, но коментирах моя код навсякъде много добре, така че не трябва да се губите в него. Вижте окончателния код за тази част 5 тук на GitHub:

Вижте източника в GitHub.

Най-голямото предизвикателство за мен лично не беше да науча нови технологии, не да разработя приложението, а да напиша всички тези статии. Всъщност съм много доволен от себе си, че завърших това предизвикателство. Ученето и развитието е лесно в сравнение с обучението на другите, но именно тук можете да разберете темата още по-добре. Моят съвет, ако търсите най-добрия начин да научите нови неща, е да започнете да създавате нещо сами веднага. Обещавам, че ще научите много и бързо.

Всички тези статии са базирани на версия 1.0.0 на приложението „Kriptofolio“ (по-рано „Моите крипто монети“), което можете да изтеглите тук като отделен APK файл. Но ще се радвам много, ако инсталирате и оцените най-новата версия на приложението директно от магазина:

Вземете го в Google Play

Също така, не се колебайте да посетите този прост уебсайт за презентации, който направих за този проект:

Kriptofolio.app

Ačiū! Благодаря за четенето! Първоначално публикувах тази публикация за личния си блог www.baruckis.com на 11 май 2019 г.