كيفية التعامل مع خدمات الويب RESTful باستخدام التعديل التحديثي و OkHttp و Gson و Glide و Coroutines

سلسلة تطبيقات Kriptofolio - الجزء الخامس

في هذه الأيام ، يتصل كل تطبيق Android تقريبًا بالإنترنت للحصول على / إرسال البيانات. يجب أن تتعلم بالتأكيد كيفية التعامل مع خدمات الويب RESTful ، حيث أن تنفيذها الصحيح هو المعرفة الأساسية أثناء إنشاء تطبيقات حديثة.

هذا الجزء سيكون معقدًا. سنقوم بدمج مكتبات متعددة في وقت واحد للحصول على نتيجة عملية. لن أتحدث عن طريقة Android الأصلية للتعامل مع طلبات الإنترنت ، لأنه في العالم الحقيقي لا أحد يستخدمها. لا يحاول كل تطبيق جيد إعادة اختراع العجلة ولكنه يستخدم بدلاً من ذلك مكتبات الطرف الثالث الأكثر شيوعًا لحل المشكلات الشائعة. سيكون من المعقد للغاية إعادة إنشاء الوظائف التي يجب أن تقدمها هذه المكتبات جيدة الصنع.

محتوى المسلسل

  • مقدمة: خارطة طريق لإنشاء تطبيق Android حديث في 2018-2019
  • الجزء 1: مقدمة لمبادئ SOLID
  • الجزء 2: كيفية البدء في إنشاء تطبيق Android الخاص بك: إنشاء تخطيطات Mockups و UI و XML
  • الجزء 3: كل شيء عن تلك البنية: استكشاف أنماط معمارية مختلفة وكيفية استخدامها في تطبيقك
  • الجزء 4: كيفية تنفيذ Dependency Injection في تطبيقك باستخدام Dagger 2
  • الجزء 5: التعامل مع خدمات الويب RESTful باستخدام التعديل التحديثي و OkHttp و Gson و Glide و Coroutines (أنت هنا)

ما هو التحديثية و OkHttp و Gson؟

التعديل التحديثي هو عميل REST لجافا وأندرويد. هذه المكتبة ، في رأيي ، هي أهم مكتبة يجب تعلمها ، لأنها ستقوم بالمهمة الرئيسية. يجعل من السهل نسبيًا استرداد وتحميل JSON (أو البيانات المنظمة الأخرى) عبر خدمة ويب قائمة على REST.

في التعديل التحديثي ، تقوم بتكوين أي محول يتم استخدامه لتسلسل البيانات. عادةً لتسلسل العناصر وإلغاء تسلسلها من JSON وإليها ، تستخدم مكتبة Java مفتوحة المصدر - Gson. أيضًا إذا كنت بحاجة ، يمكنك إضافة محولات مخصصة إلى التعديل التحديثي لمعالجة XML أو البروتوكولات الأخرى.

لعمل طلبات HTTP ، يستخدم التعديل التحديثي مكتبة OkHttp. OkHttp هو عميل HTTP / SPDY خالص مسؤول عن أي عمليات شبكة منخفضة المستوى والتخزين المؤقت والطلبات والتلاعب بالردود. في المقابل ، التعديل التحديثي هو تجريد REST عالي المستوى مبني على قمة OkHttp. يقترن التعديل التحديثي بقوة بـ OkHttp ويستخدمه بشكل مكثف.

الآن بعد أن عرفت أن كل شيء مرتبط ارتباطًا وثيقًا ، سنستخدم كل هذه المكتبات الثلاثة في وقت واحد. هدفنا الأول هو الحصول على كل قائمة العملات المشفرة باستخدام التعديل التحديثي من الإنترنت. سنستخدم فئة معترض OkHttp خاصة لمصادقة CoinMarketCap API عند إجراء مكالمة إلى الخادم. سنعيد نتيجة بيانات JSON ثم نحولها باستخدام مكتبة Gson.

الإعداد السريع لـ Retrofit 2 فقط لتجربته أولاً

عندما أتعلم شيئًا جديدًا ، أحب أن أجربه في الممارسة بأسرع ما يمكن. سنطبق نهجًا مشابهًا مع التحديث 2 لكي تفهمه بشكل أفضل بسرعة أكبر. لا تقلق الآن بشأن جودة الكود أو أي مبادئ برمجة أو تحسينات - سنقوم فقط بكتابة بعض التعليمات البرمجية لجعل التعديل 2 يعمل في مشروعنا ومناقشة ما يفعله.

اتبع هذه الخطوات لإعداد Retrofit 2 في مشروع تطبيق My Crypto Coins:

أولاً ، امنح إذن الإنترنت للتطبيق

سنقوم بتنفيذ طلبات HTTP على خادم يمكن الوصول إليه عبر الإنترنت. امنح هذا الإذن بإضافة هذه الأسطر إلى ملف Manifest الخاص بك:

  ... 

ثم يجب عليك إضافة تبعيات المكتبة

ابحث عن أحدث إصدار من التعديل التحديثي. يجب أن تعلم أيضًا أن التعديل التحديثي لا يأتي مع محول JSON مدمج. نظرًا لأننا سنحصل على ردود بتنسيق JSON ، فنحن بحاجة إلى تضمين المحول يدويًا في التبعيات أيضًا. سنستخدم أحدث إصدار من محول JSON من Google Gson. دعنا نضيف هذه الأسطر إلى ملف 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 بالفعل مع تبعية التعديل 2. الإصدارات هي مجرد ملف 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. }

وقم بإعداد فئة البيانات

فئات البيانات هي POJOs (كائنات Java القديمة البسيطة) التي تمثل استجابات استدعاءات 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) المقدم من بوابة مطوري واجهة برمجة تطبيقات CoinMarketCap Professional. للحصول على لك ، تحتاج إلى التسجيل هناك.

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

حسنًا ، بعد تجربة سريعة ، حان الوقت لنقل تنفيذ التعديل التحديثي إلى المستوى التالي. لقد حصلنا بالفعل على البيانات بنجاح ولكن ليس بشكل صحيح. نحن نفتقد حالات مثل التحميل والخطأ والنجاح. كودنا مختلط دون فصل الاهتمامات. من الخطأ الشائع كتابة كل التعليمات البرمجية في نشاط أو جزء. تعتمد فئة النشاط لدينا على واجهة المستخدم ويجب أن تحتوي فقط على المنطق الذي يعالج تفاعلات واجهة المستخدم ونظام التشغيل.

في الواقع ، بعد هذا الإعداد السريع ، عملت كثيرًا وقمت بالعديد من التغييرات. لا جدوى من وضع كل الكود الذي تم تغييره في المقالة. من الأفضل بدلاً من ذلك أن تتصفح النسخة النهائية من كود الجزء الخامس هنا. لقد علقت على كل شيء جيدًا ويجب أن يكون الكود الخاص بي واضحًا لك لتفهمه. لكنني سأتحدث عن أهم الأشياء التي قمت بها ولماذا فعلتها.

كانت الخطوة الأولى للتحسين هي البدء في استخدام حقن التبعية. تذكر من الجزء السابق أنه تم تنفيذ 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. بشكل افتراضي ، يُرجع التعديل التحديثي 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 وبيانات من خدمات الويب) ، لكن 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فئة abstract. ما هو ولماذا نحتاجه؟

NetworkBoundResource is a small but very important helper class that will allow us to maintain a synchronization between the local database and the web service. Our goal is to build a modern application that will work smoothly even when our device is offline. Also with the help of this class we will be able to present different network states like errors or loading for the user visually.

NetworkBoundResource starts by observing the database for the resource. When the entry is loaded from the database for the first time, it checks whether the result is good enough to be dispatched or if it should be re-fetched from the network. Note that both of these situations can happen at the same time, given that you probably want to show cached data while updating it from the network.

If the network call completes successfully, it saves the response into the database and re-initializes the stream. If the network request fails, the NetworkBoundResource dispatches a failure directly.

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

Under the hood, the NetworkBoundResource class is made by using MediatorLiveData and its ability to observe multiple LiveData sources at once. Here we have two LiveData sources: the database and the network call response. Both of those LiveData are wrapped into one MediatorLiveData which is exposed by NetworkBoundResource.

Let’s take a closer look how the NetworkBoundResource will work in our app. Imagine the user will launch the app and click on a floating action button on the bottom right corner. The app will launch the add crypto coins screen. Now we can analyze NetworkBoundResource's usage inside it.

If the app is freshly installed and it is its first launch, then there will not be any data stored inside the local database. Because there is no data to show, a loading progress bar UI will be shown. Meanwhile the app is going to make a request call to the server via a web service to get all the cryptocurrencies list.

If the response is unsuccessful then the error message UI will be shown with the ability to retry a call by pressing a button. When a request call is successful at last, then the response data will be saved to a local SQLite database.

If we come back to the same screen the next time, the app will load data from the database instead of making a call to the internet again. But the user can ask for a new data update by implementing pull-to-refresh functionality. Old data information will be shown whilst the network call is happening. All this is done with the help of NetworkBoundResource.

Another class used in our Repository and LiveDataCallAdapter where all the "magic" happens is ApiResponse. Actually ApiResponse is just a simple common wrapper around the Retrofit2.Response class that converts each response to an instance of 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. لقد أضفناها بالفعل عند إنشاء مثيل منشئ التعديل التحديثي GsonConverterFactoryبداخل AppModuleوظيفة Dagger provideApiService.

انزلق لتحميل الصور

ما هو الانزلاق؟ من المستندات:

يعد Glide إطارًا سريعًا وفعالًا لإدارة الوسائط وتحميل الصور لنظام Android ، والذي يلف فك تشفير الوسائط والذاكرة والتخزين المؤقت للقرص وتجميع الموارد في واجهة بسيطة وسهلة الاستخدام. ينصب التركيز الأساسي لـ Glide على جعل التمرير لأي نوع من القوائم من الصور على نحو سلس وسريع قدر الإمكان ، ولكنه فعال أيضًا في أي حالة تقريبًا حيث تحتاج إلى جلب صورة بعيدة وتغيير حجمها وعرضها.

Sounds like a complicated library which offers many useful features that you would not want to develop all by yourself. In My Crypto Coins app, we have several list screens where we need to show multiple cryptocurrency logos — pictures taken from the internet all at once — and still ensure a smooth scrolling experience for the user. So this library fits our needs perfectly. Also this library is very popular among Android developers.

Steps to setup Glide on My Crypto Coins app project:

Declare dependencies

Get the latest Glide version. Again versions is a separate file versions.gradle for the project.

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

Because we want to use the networking library OkHttp in our project for all network operations, we need to include the specific Glide integration for it instead of the default one. Also since Glide is going to perform a network request to load images via the internet, we need to include the permission INTERNET in our AndroidManifest.xml file — but we already did that with the Retrofit setup.

Create AppGlideModule

Glide v4, which we will be using, offers a generated API for Applications. It will use an annotation processor to generate an API that allows applications to extend Glide’s API and include components provided by integration libraries. For any app to access the generated Glide API we need to include an appropriately annotated AppGlideModule implementation. There can be only a single implementation of the generated API and only one AppGlideModule per application.

Let’s create a class extending AppGlideModule somewhere in your app project:

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

Even if our application is not changing any additional settings or implementing any methods in AppGlideModule, we still need to have its implementation to use Glide. You're not required to implement any of the methods in AppGlideModule for the API to be generated. You can leave the class blank as long as it extends AppGlideModule and is annotated with @GlideModule.

Use Glide-generated API

When using AppGlideModule, applications can use the API by starting all loads with GlideApp.with(). This is the code that shows how I have used Glide to load and show cryptocurrency logos in the add crypto coins screen all cryptocurrencies list.

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

As you see, you can start using Glide with just few lines of code and let it do all the hard work for you. It is pretty straightforward.

Kotlin Coroutines

While building this app, we are going to face situations when we will run time consuming tasks such as writing data to a database or reading from it, fetching data from the network and other. All these common tasks take longer to complete than allowed by the Android framework’s main thread.

The main thread is a single thread that handles all updates to the UI. Developers are required not to block it to avoid the app freezing or even crashing with an Application Not Responding dialog. Kotlin coroutines is going to solve this problem for us by introducing main thread safety. It is the last missing piece that we want to add for My Crypto Coins app.

Coroutines are a Kotlin feature that convert async callbacks for long-running tasks, such as database or network access, into sequential code. With coroutines, you can write asynchronous code, which was traditionally written using the Callback pattern, using a synchronous style. The return value of a function will provide the result of the asynchronous call. Code written sequentially is typically easier to read, and can even use language features such as exceptions.

So we are going to use coroutines everywhere in this app where we need to wait until a result is available from a long-running task and than continue execution. Let’s see one exact implementation for our ViewModel where we will retry getting the latest data from the server for our cryptocurrencies presented on the main screen.

First add coroutines to the project:

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

Then we will create abstract class which will become the base class to be used for any ViewModel that needs to have common functionality like coroutines in our case:

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

Here we create specific coroutine scope, which will control the lifetime of coroutines through its job. As you see, scope allows you to specify a default dispatcher that controls which thread runs a coroutine. When the ViewModel is no longer used, we cancel viewModelJob and with that every coroutine started by uiScope will be cancelled as well.

Finally, implement the retry functionality:

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

Here we call a function marked with a special Kotlin keyword suspend for coroutines. This means that the function suspends execution until the result is ready, then it resumes where it left off with the result. While it is suspended waiting for a result, it unblocks the thread that it is running on.

Also, in one suspend function we can call another suspend function. As you see we do that by calling new suspend function marked withContext that is executed on different thread.

The idea of all this code is that we can combine multiple calls to form nice-looking sequential code. First we request to get the ids of the cryptocurrencies we own from the local database and wait for the response. Only after we get it do we use the response ids to make a new call with Retrofit to get those updated cryptocurrency values. That is our retry functionality.

We made it! Final thoughts, repository, app & presentation

Congratulations, I am happy if you managed to reach to the end. All the most significant points for creating this app have been covered. There was plenty of new stuff done in this part and a lot of that is not covered by this article, but I commented my code everywhere very well so you should not get lost in it. Check out final code for this part 5 here on GitHub:

View Source On GitHub.

The biggest challenge for me personally was not to learn new technologies, not to develop the app, but to write all these articles. Actually I am very happy with myself that I completed this challenge. Learning and developing is easy compared to teaching others, but that is where you can understand the topic even better. My advice if you are looking for the best way to learn new things is to start creating something yourself immediately. I promise you will learn a lot and quickly.

All these articles are based on version 1.0.0 of “Kriptofolio” (previously “My Crypto Coins”) app which you can download as a separate APK file here. But I will be very happy if you install and rate the latest app version from the store directly:

Get It On Google Play

Also please feel free to visit this simple presentation website that I made for this project:

Kriptofolio.app

Ai! شكرا للقراءة! لقد نشرت هذا المنشور في الأصل لمدونتي الشخصية www.baruckis.com في 11 مايو 2019.