Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> ระบบ >> Android

เชี่ยวชาญ RESTful Services ใน Android: Retrofit, OkHttp, Gson, Glide & Coroutines

Kriptofolio app series — Part 5

ตอนนี้เกือบทุก Android app เชื่อมต่ออินเทอร์เน็ตเพื่อ get/send data คุณควรเรียนรู้การจัดการ RESTful Web Services เพราะ implementation ที่ถูกต้องคือ core knowledge สร้าง modern apps

Part นี้จะซับซ้อน เราจะรวมหลาย libraries พร้อมกันเพื่อผลลัพธ์ที่ทำงาน ไม่พูดถึง native Android way จัดการ internet requests เพราะในโลกจริงไม่มีใครใช้ ทุกแอปที่ดีไม่พยายาม reinvent the wheel แต่ใช้ third-party libraries ยอดนิยมแก้ปัญหาทั่วไป จะซับซ้อนเกินไปถ้า recreate functionality ที่ libraries ทำได้ดี

Series content

  • Introduction: Roadmap สร้าง modern Android app ปี 2018–2019
  • Part 1: Introduction to SOLID principles
  • Part 2: เริ่มสร้าง Android app: Mockups, UI, XML layouts
  • Part 3: Architecture patterns และการใช้ในแอป
  • Part 4: Dependency Injection ด้วย Dagger 2
  • Part 5: RESTful Web Services ด้วย Retrofit, OkHttp, Gson, Glide และ Coroutines (คุณอยู่ที่นี่)

Retrofit, OkHttp และ Gson คืออะไร?

Retrofit เป็น REST Client สำหรับ Java และ Android ไลบรารีนี้ เป็นสำคัญที่สุดที่ควรเรียน เพราะทำหน้าที่หลัก ทำให้ retrieve และ upload JSON (หรือ structured data อื่นๆ) ผ่าน REST webservice ง่ายขึ้น

ใน Retrofit คุณ config converter สำหรับ data serialization ปกติ serialize/deserialize objects ไป/มาจาก JSON ใช้ Gson (open-source Java library) หากต้องการ เพิ่ม custom converters ให้ Retrofit ประมวลผล XML หรือ protocols อื่นๆ

สำหรับ HTTP requests Retrofit ใช้ OkHttp library OkHttp เป็น HTTP/SPDY client บริสุทธิ์ รับผิดชอบ low-level network operations, caching, requests และ responses manipulation ในทางกลับกัน Retrofit เป็น high-level REST abstraction สร้างบน OkHttp Retrofit ผูกติดกับ OkHttp อย่างแน่นและใช้งานมาก

ตอนนี้รู้ว่าทุกอย่างเกี่ยวข้อง เราจะใช้ทั้ง 3 libraries พร้อมกัน เป้าหมายแรก: ดึงรายการ cryptocurrencies ทั้งหมดด้วย Retrofit จากอินเทอร์เน็ต ใช้ OkHttp interceptor class 特別สำหรับ CoinMarketCap API authentication ตอน call server ได้ JSON result แปลงด้วย Gson

Quick setup Retrofit 2 เพื่อลองก่อน

ตอนเรียนรู้ใหม่ ผมลอง practical ทันที เราจะทำแบบเดียวกับ Retrofit 2 เพื่อเข้าใจเร็วขึ้น ไม่ต้องกังวล code quality หรือ principles/optimizations — เขียนโค้ดให้ Retrofit 2 ทำงานในโปรเจกต์และอธิบายว่าทำอะไร

ทำตามขั้นตอน setup Retrofit 2 บน My Crypto Coins app project:

ขั้นแรก ให้ INTERNET permission สำหรับแอป

เราจะ execute HTTP requests บน server เข้าถึงได้ผ่านอินเทอร์เน็ต ให้ permission โดยเพิ่มบรรทัดใน Manifest file:

<manifest xmlns:android="https://schemas.android.com/apk/res/android" package="com.baruckis.mycryptocoins"> <uses-permission android:name="android.permission.INTERNET" /> ... </manifest>

เพิ่ม library dependencies

หา Retrofit version ล่าสุด Retrofit ไม่ ship with integrated JSON converter เนื่องจากเราได้ responses เป็น JSON ต้อง include converter manually ใน dependencies ใช้ Google's JSON converter Gson เวอร์ชันล่าสุด เพิ่มใน gradle file:

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

เห็นจาก comment OkHttp dependency รวมใน Retrofit 2 dependency แล้ว Versions คือ gradle file แยกสำหรับความสะดวก:

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

ตั้งค่า Retrofit interface

เป็น interface ประกาศ requests และ types ที่นี่ define APIฝั่ง client

/** * REST API access points. */ interface ApiService { // @GET annotation บอก retrofit request นี้เป็น GET type // string value บอก retrofit path คือ baseUrl + v1/cryptocurrency/listings/latest + query parameter @GET("v1/cryptocurrency/listings/latest") // @Query annotation ใช้ define query parameter สุดท้าย request url จะเป็น https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?convert=EUR fun getAllCryptocurrencies(@Query("convert") currency: String): Call<CryptocurrenciesLatest> // return type คือ Call with type CryptocurrenciesLatest }

ตั้งค่า data class

Data classes เป็น POJOs แทน responses ของ API calls ที่จะทำ

/** * Data class handle response จาก server. */ data class CryptocurrenciesLatest( val status: Status, val data: List<Data> ) { data class Data( val id: Int, val name: String, val symbol: String, val slug: String, @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( @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 ) }

สร้าง interceptor class พิเศษสำหรับ authentication ตอน call server

กรณีนี้เฉพาะ API ที่ต้อง authentication เพื่อ response สำเร็จ Interceptors คือวิธี customize requests อันทรงพลัง เราจะ intercept actual request และเพิ่ม custom request headers validate call ด้วย API Key จาก CoinMarketCap Professional API Developer Portal สมัครที่นั่นเพื่อได้ API Key

/** * Interceptor ใช้ intercept actual request และ * ให้ API Key ใน REST API calls ผ่าน custom header. */ class AuthenticationInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val newRequest = chain.request().newBuilder() // TODO: ใช้ API Key จาก CoinMarketCap Professional API Developer Portal .addHeader("X-CMC_PRO_API_KEY", "CMC_PRO_API_KEY") .build() return chain.proceed(newRequest) } }

เพิ่มโค้ดนี้ใน Activity เพื่อดู Retrofit ทำงาน

ผมอยากให้ลองเร็วที่สุด จึงรวมทุกอย่างไว้ที่เดียว ไม่ใช่วิธีที่ถูกต้อง แต่เร็วที่สุดเพื่อดู visual result

class AddSearchActivity : AppCompatActivity(), Injectable { private lateinit var listView: ListView private lateinit var listAdapter: AddSearchListAdapter ... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... // ตั้งค่า Retrofit อย่างถูกต้องภายหลัง แต่ตอนนี้รวมไว้ที่เดียวเพื่อ quick start setupRetrofitTemporarily() } ... private fun setupRetrofitTemporarily() { // ต้องเตรียม custom OkHttp client เนื่องจากต้องใช้ custom call interceptor // เพื่อ authenticate requests val builder = OkHttpClient.Builder() // เพิ่ม interceptor ให้ OkHttpClient // จะเพิ่ม authentication headers ทุก call ที่เราทำ builder.interceptors().add(AuthenticationInterceptor()) val client = builder.build() val api = Retrofit.Builder() // สร้าง retrofit builder .baseUrl("https://sandbox-api.coinmarketcap.com/") // Base url ต้องลงท้ายด้วย slash .addConverterFactory(GsonConverterFactory.create()) // ใช้ GSON converter JSON to POJO mapping .client(client) // ตั้งค่า custom OkHttp client ที่สร้าง .build().create(ApiService::class.java) // สร้าง API จาก interface ที่ define val adapterData: MutableList<Cryptocurrency> = ArrayList<Cryptocurrency>() val currentFiatCurrencyCode = "EUR" // ทำ asynchronous network request ดึง cryptocurrencies ล่าสุดจาก server // query parameter ส่ง "EUR" เพราะอยากได้ราคาเป็นยูโร val call = api.getAllCryptocurrencies("EUR") val result = call.enqueue(object : Callback<CryptocurrenciesLatest> { // จะได้ response เสมอแม้ server ผิดพลาด override fun onFailure(call: Call<CryptocurrenciesLatest>, t: Throwable) { Snackbar.make(findViewById(android.R.id.content), // Throwable ให้หา error ถ้า call failed "Call failed! " + t.localizedMessage, Snackbar.LENGTH_INDEFINITE).show() } override fun onResponse(call: Call<CryptocurrenciesLatest>, response: Response<CryptocurrenciesLatest>) { // Check response successful คือ request received, understood, accepted และ return code ใน [200..300) if (response.isSuccessful) { // ถ้า OK บอก user Toast.makeText(this@AddSearchActivity, "Call OK.", Toast.LENGTH_LONG).show(); // map server response data ไป 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 ถ้า response unsuccessful จะเป็น HTTP error code พิเศษ แสดงให้ user else Snackbar.make(findViewById(android.R.id.content), "Call error with HTTP status code " + response.code() + "!", Snackbar.LENGTH_INDEFINITE).show() } }) } ... }

สำรวจโค้ดได้ที่นี่ จำไว้ว่าเป็น initial simplified implementation version เพื่อให้เข้าใจ idea ดีกว่า

Final correct setup Retrofit 2 กับ OkHttp 3 และ Gson

OK หลังลองเร็ว ถึงเวลา bring Retrofit implementation สู่อย่างถูกต้อง เราได้ data สำเร็จแต่ไม่ถูกต้อง ขาด states เช่น loading, error, success โค้ด mixed without separation of concerns ผิดทั่วไปที่เขียนโค้ดทั้งหมดใน activity/fragment Activity class เป็น UI based ควรมี logic จัดการ UI และ OS interactions เท่านั้น

หลัง quick setup ผมทำงานเยอะและเปลี่ยนแปลงมาก ไม่ต้องใส่โค้ดที่เปลี่ยนทั้งหมดในบทความ ดีกว่า browse final Part 5 code repo ที่นี่ ผม comment ทุกอย่างดี โค้ดควรเข้าใจง่าย แต่จะพูดถึงสิ่งสำคัญที่ทำและทำไม

ขั้นแรกปรับปรุงคือใช้ Dependency Injection จำได้จาก part ก่อนหน้า Dagger 2 implemented ในโปรเจกต์ถูกต้องแล้ว ใช้มัน setup Retrofit

/** * AppModule จะ provide app-wide dependencies สำหรับส่วนของแอป * ควร initialize objects ใช้ทั่วแอป เช่น Room database, Retrofit, Shared Preference ฯลฯ */ @Module(includes = [ViewModelsModule::class]) class AppModule() { ... @Provides @Singleton fun provideHttpClient(): OkHttpClient { // ต้องเตรียม custom OkHttp client เนื่องจากต้องใช้ custom call interceptor // เพื่อ authenticate requests val builder = OkHttpClient.Builder() // เพิ่ม interceptor ให้ OkHttpClient // จะเพิ่ม authentication headers ทุก call builder.interceptors().add(AuthenticationInterceptor()) // Config client ไม่ retry เมื่อเจอ connectivity problem builder.retryOnConnectionFailure(false) // Log requests และ responses // เพิ่ม logging เป็น interceptor สุดท้าย เพราะจะ log ข้อมูลที่เพิ่ม/จัดการด้วย interceptors ก่อนหน้า builder.interceptors().add(HttpLoggingInterceptor().apply { // สำหรับ production เพื่อ performance จะ skip logging operations แสดง logs แค่ 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() // สร้าง retrofit builder .baseUrl(API_SERVICE_BASE_URL) // Base url ต้องลงท้าย slash .addConverterFactory(GsonConverterFactory.create()) // ใช้ GSON converter JSON to POJO mapping .addCallAdapterFactory(LiveDataCallAdapterFactory()) .client(httpClient) // ตั้งค่า custom OkHttp client .build().create(ApiService::class.java) // สร้าง API จาก interface ที่ define } ... }

ตอนนี้ Retrofit แยกจาก activity class แล้ว จะ initialize ครั้งเดียวและใช้ app-wide

สังเกตตอนสร้าง Retrofit builder instance เราเพิ่ม special Retrofit calls adapter 用 addCallAdapterFactory โดย default Retrofit คืน Call<T> แต่โปรเจกต์เราต้องการ LiveData<T> ต้องเพิ่ม LiveDataCallAdapter โดย LiveDataCallAdapterFactory

/** * Retrofit adapter แปลง Call เป็น LiveData of ApiResponse * @param <R> </R> */ class LiveDataCallAdapter<R>(private val responseType: Type) : CallAdapter<R, LiveData<ApiResponse<R>>> { override fun responseType() = responseType override fun adapt(call: Call<R>): LiveData<ApiResponse<R>> { return object : LiveData<ApiResponse<R>>() { private var started = AtomicBoolean(false) override fun onActive() { super.onActive() if (started.compareAndSet(false, true)) { call.enqueue(object : Callback<R> { override fun onResponse(call: Call<R>, response: Response<R>) { postValue(ApiResponse.create(response)) } override fun onFailure(call: Call<R>, throwable: Throwable) { postValue(ApiResponse.create(throwable)) } }) } } } }
class LiveDataCallAdapterFactory : CallAdapter.Factory() { override fun get( returnType: Type, annotations: Array<Annotation>, 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<Any>(bodyType) } }

ตอนนี้จะได้ LiveData<T> แทน Call<T> เป็น return type จาก Retrofit service methods ใน ApiService interface

ขั้นตอนสำคัญอีกอย่าง เริ่มใช้ Repository pattern พูดถึงใน Part 3 เช็ค MVVM architecture schema จากโพสต์นั้น

เชี่ยวชาญ RESTful Services ใน Android: Retrofit, OkHttp, Gson, Glide & Coroutines

เห็นในรูป Repository เป็น layer แยกสำหรับ data เป็น single source of contact สำหรับ get/send data ใช้ Repository follow separation of concerns สามารถมี data sources ต่างๆ (เช่น persistent data จาก SQLite และ data จาก web services) แต่ Repository เป็น single source of truth สำหรับ app data ทั้งหมด

แทนที่จะ communicate กับ Retrofit implementation โดยตรง ใช้ Repository สำหรับแต่ละ entity จะมี Repository แยก

/** * Class จัดการ 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 ) { // Helper variable เก็บ selected fiat currency code ระหว่าง app lifecycle // จำเป็นสำหรับ main screen currency spinner ตั้งค่าเท่ากับ shared preferences var selectedFiatCurrencyCode: String = getCurrentFiatCurrencyCode() ... // Resource wrapping ของ LiveData มีประโยชน์ update UI ตาม state fun getAllCryptocurrencyLiveDataResourceList(fiatCurrencyCode: String, shouldFetch: Boolean = false, callDelay: Long = 0): LiveData<Resource<List<Cryptocurrency>>> { return object : NetworkBoundResource<List<Cryptocurrency>, CoinMarketCap<List<CryptocurrencyLatest>>>(appExecutors) { // บันทึก data fetched จาก web-service override fun saveCallResult(item: CoinMarketCap<List<CryptocurrencyLatest>>) { val list = getCryptocurrencyListFromResponse(fiatCurrencyCode, item.data, item.status?.timestamp) cryptocurrencyDao.reloadCryptocurrencyList(list) myCryptocurrencyDao.reloadMyCryptocurrencyList(list) } // คืนค่า boolean ว่า fetch data จาก web หรือไม่ true = fetch from web override fun shouldFetch(data: List<Cryptocurrency>?): Boolean { return data == null || shouldFetch } override fun fetchDelayMillis(): Long { return callDelay } // Logic ดึงข้อมูลจาก Room database override fun loadFromDb(): LiveData<List<Cryptocurrency>> { return Transformations.switchMap(cryptocurrencyDao.getAllCryptocurrencyLiveDataList()) { data -> if (data.isEmpty()) { AbsentLiveData.create() } else { cryptocurrencyDao.getAllCryptocurrencyLiveDataList() } } } // Logic ดึงข้อมูลจาก web-service 用 Retrofit override fun createCall(): LiveData<ApiResponse<CoinMarketCap<List<CryptocurrencyLatest>>>> = 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<CryptocurrencyLatest>?, timestamp: Date?): ArrayList<Cryptocurrency> { val cryptocurrencyList: MutableList<Cryptocurrency> = 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<Cryptocurrency> } }

สังเกตใน class CryptocurrencyRepository ใช้ NetworkBoundResource abstract class คืออะไร และต้องการมันทำไม?

NetworkBoundResource เป็น helper class เล็กแต่สำคัญ ให้ maintain synchronization ระหว่าง local database และ web service เป้าหมายสร้าง modern app ทำงาน smooth แม้อุปกรณ์ offline ด้วยช่วยจาก class นี้ จะ present network states ต่างๆ เช่น errors หรือ loading ให้ user ดูได้ visual

NetworkBoundResource เริ่มโดย observe database สำหรับ resource เมื่อ load entry จาก database ครั้งแรก ตรวจสอบว่า result พอจะ dispatch หรือควร re-fetch จาก network ทั้งสองอาจเกิดพร้อมกัน เพราะอาจอยากแสดง cached data ขณะ update จาก network

ถ้า network call สำเร็จ บันทึก response ลง database และ re-initialize stream ถ้า network request fail NetworkBoundResource dispatch failure โดยตรง

/** * Generic class provide resource backed by sqlite database และ network * * อ่านเพิ่มใน [Architecture * Guide](https://developer.android.com/arch) * @param <ResultType> - Type for Resource data * @param <RequestType> - Type for API response </RequestType></ResultType> */ // Define 2 type parameters ResultType และ RequestType // เพราะ data type จาก API อาจไม่ match data type ใช้ locally abstract class NetworkBoundResource<ResultType, RequestType> @MainThread constructor(private val appExecutors: AppExecutors) { // Final result LiveData private val result = MediatorLiveData<Resource<ResultType>>() init { // ส่ง loading state ไป 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<ResultType>) { if (result.value != newValue) { result.value = newValue } } // Fetch data from network persist into DB และส่งกลับ UI private fun fetchFromNetwork(dbSource: LiveData<ResultType>) { val apiResponse = createCall() // Re-attach dbSource as new source จะ dispatch latest value เร็ว result.addSource(dbSource) { newData -> setValue(Resource.loading(newData)) } // สร้าง inner function เพื่อ delay ถ้าต้องการ 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 { // ขอ live data ใหม่โดยเฉพาะ // ไม่งั้นจะได้ cached value ทันที // ซึ่งอาจไม่ updated กับ latest results จาก 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 ก่อน call ถ้าต้องการ val delay = fetchDelayMillis() if (delay > 0) { Handler().postDelayed({ fetch() }, delay) } else fetch() } // Called when fetch fails Child class อาจ reset components // like rate limiter protected open fun onFetchFailed() {} // คืนค่า LiveData object represent resource ที่ implement ใน base class fun asLiveData() = result as LiveData<Resource<ResultType>> @WorkerThread protected open fun processResponse(response: ApiSuccessResponse<RequestType>) = response.body // Called to save API response result ลง database @WorkerThread protected abstract fun saveCallResult(item: RequestType) // Called with data ใน database 决定ว่า fetch potentially updated data จาก network หรือไม่ @MainThread protected abstract fun shouldFetch(data: ResultType?): Boolean // Call server หลัง delay สำหรับ UX ดีขึ้น protected open fun fetchDelayMillis(): Long = 0 // Called to get cached data จาก database @MainThread protected abstract fun loadFromDb(): LiveData<ResultType> // Called to create API call @MainThread protected abstract fun createCall(): LiveData<ApiResponse<RequestType>> }

ใต้ฝาผิว NetworkBoundResource class สร้างโดยใช้ MediatorLiveData และความสามารถ observe multiple LiveData sources พร้อมกัน ที่นี่มี 2 LiveData sources: database และ network call response ทั้งสอง wrapped ใน MediatorLiveData เดียว exposed โดย NetworkBoundResource

เชี่ยวชาญ RESTful Services ใน Android: Retrofit, OkHttp, Gson, Glide & Coroutines
NetworkBoundResource

ดูว่า NetworkBoundResource ทำงานในแอปอย่างไร สมมติ user launch app กด floating action button มุมขวาล่าง แอป launch add crypto coins screen ตอนนี้ analyze NetworkBoundResource usage ภายใน

ถ้าแอปเพิ่งติดตั้งและ launch ครั้งแรก ไม่มี data ใน local database เพราะไม่มี data แสดง loading progress bar UI ขณะเดียวกัน แอป call request ไป server ผ่าน web service ดึง cryptocurrencies list

ถ้า response unsuccessful แสดง error message UI พร้อม retry call ด้วยกดปุ่ม เมื่อ request สำเร็จ response data จะ saved ไป local SQLite database

กลับมาหน้าจอเดิมครั้งต่อไป แอปโหลด data จาก database แทน call internet อีกครั้ง แต่ user ขอ update ใหม่ได้ด้วย pull-to-refresh Old data แสดงขณะ network call เกิดขึ้น ทั้งหมดทำได้ด้วย NetworkBoundResource

Class อื่นใช้ใน Repository และ LiveDataCallAdapter ที่ "magic" เกิดขึ้น คือ ApiResponse จริงๆ ApiResponse เป็น common wrapper รอบ Retrofit2.Response class แปลงแต่ละ response เป็น LiveData instance

/** * Common class used by API responses ApiResponse เป็น simple wrapper รอบ Retrofit2.Call * class ที่ convert responses เป็น instances of LiveData * @param <CoinMarketCapType> the type of the response object </T> */ @Suppress("unused") // T used in extending classes sealed class ApiResponse<CoinMarketCapType> { companion object { fun <CoinMarketCapType> create(error: Throwable): ApiErrorResponse<CoinMarketCapType> { return ApiErrorResponse(error.message ?: "Unknown error.") } fun <CoinMarketCapType> create(response: Response<CoinMarketCapType>): ApiResponse<CoinMarketCapType> { 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<CoinMarketCap<CoinMarketCapType>>() {}.type val errorResponse: CoinMarketCap<CoinMarketCapType> = 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.") } } } } /** * Class แยกสำหรับ HTTP 204 responses เพื่อทำให้ ApiSuccessResponse body non-null */ class ApiEmptyResponse<CoinMarketCapType> : ApiResponse<CoinMarketCapType>() data class ApiSuccessResponse<CoinMarketCapType>(val body: CoinMarketCapType) : ApiResponse<CoinMarketCapType>() data class ApiErrorResponse<CoinMarketCapType>(val errorMessage: String) : ApiResponse<CoinMarketCapType>()

ใน wrapper class นี้ ถ้า response มี error ใช้ Gson library convert error เป็น JSON object แต่ถ้า response successful ใช้ Gson converter for JSON to POJO mapping ที่เพิ่มตอนสร้าง retrofit builder ด้วย GsonConverterFactory ใน Dagger AppModule function provideApiService

Glide สำหรับโหลดรูปภาพ

Glide คืออะไร? จาก docs:

Glide เป็น media management และ image loading framework สำหรับ Android ที่รวดเร็วและมีประสิทธิภาพ open source wrap media decoding, memory และ disk caching, resource pooling ใน interface เรียบง่ายและใช้ง่าย
Focus หลักของ Glide คือทำให้ scrolling list ของรูปภาพทุกประเภท smooth และ fast ที่สุด แต่ก็ effective สำหรับทุกกรณีที่ต้อง fetch, resize, และแสดง remote image

聽起來ซับซ้อนแต่อย่างน้อย features ที่ไม่คอย develop เอง ใน My Crypto Coins app มีหลาย list screens ต้องแสดง cryptocurrency logos — รูปจากอินเทอร์เน็ตพร้อมกันหลายรูป — และยัง ensure smooth scrolling ให้ user ไลบรารีนี้ตรงความต้องการมาก ยังเป็นที่นิยมใน Android developers

Steps setup Glide บน My Crypto Coins app project:

ประกาศ dependencies

หา Glide version ล่าสุด versions คือไฟล์แยก 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"

เพราะอยากใช้ networking library OkHttp ในโปรเจกต์สำหรับ network operations ทั้งหมด ต้อง include Glide integration สำหรับ OkHttp แทน default integration เพราะ Glide จะ network request โหลดรูปผ่านอินเทอร์เน็ต ต้อง include permission INTERNET ใน AndroidManifest.xml — แต่ทำแล้วตอน setup Retrofit

สร้าง AppGlideModule

Glide v4 ที่ใช้ ให้ generated API สำหรับ Applications ใช้ annotation processor generate API ให้ applications extend Glide's API และ include components จาก integration libraries สำหรับ app ใดๆ เข้าถึง generated Glide API ต้อง include appropriately annotated AppGlideModule implementation มีได้เพียง 1 implementation ของ generated API และ 1 AppGlideModule ต่อ application

สร้าง class extends AppGlideModule ที่ไหนก็ได้ใน app project:

/** * Glide v4 ใช้ annotation processor generate API ให้ applications เข้าถึง options ทั้งหมดใน * RequestBuilder, RequestOptions และ integration libraries ที่ include ใน single fluent API * * Generated API serve 2 purposes: * Integration libraries extend Glide's API with custom options * Applications extend Glide's API โดยเพิ่ม methods bundle commonly used options * * แม้ทั้งสอง tasks ทำด้วยมือได้โดย custom subclasses of RequestOptions แต่ challenging และ API น้อย fluent * */ @GlideModule class AppGlideModule : AppGlideModule()

แม้แอปไม่เปลี่ยน settings หรือ implement methods ใน AppGlideModule ยังต้องมี implementation เพื่อใช้ Glide ไม่บังคับ implement methods ใน AppGlideModule สำหรับ API generation ทิ้ง class ว่างได้ถ้า extends AppGlideModule และ annotated @GlideModule

ใช้ Glide-generated API

用 AppGlideModule applications ใช้ API โดยเริ่ม loads ทั้งหมดด้วย GlideApp.with() โค้ดแสดงวิธีผมใช้ Glide โหลดและแสดง cryptocurrency logos ใน 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 ... // สร้าง Uri ของรูปที่ต้องโหลด ทุก image unique name คือ 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 // ต้อง provide context เพื่อ call .with(itemBinding.root) // ระบุรูปที่ต้องโหลดด้วย Uri .load(imageUri) // Combine และ execute multiple transformations // WhiteBackground คือ custom transformation ของเรา // CircleCrop เป็น default transformation ที่ Glide มี .transform(MultiTransformation(WhiteBackground(), CircleCrop())) // Target ImageView ที่รูปควรแสดง .into(itemBinding.itemImageIcon.imageview_front) ... return itemBinding.root } ... }

เห็นได้ว่าใช้ Glide ได้เพียง幾บรรทัด ให้มันทำ heavy lifting ทั้งหมด ดู straightforward

Kotlin Coroutines

ขณะสร้างแอป จะเจอ situations ที่ run time-consuming tasks เช่น เขียน data ลง database หรืออ่านจากมัน fetch data จาก network และอื่นๆ ทุก common tasks ใช้เวลานานกว่าที่ Android framework's main thread อนุญาต

Main thread เป็น single thread จัดการ UI updates ทั้งหมด Developers ห้าม block เพื่อกัน app freezing หรือ crash กับ Application Not Responding dialog Kotlin coroutines จะแก้ปัญหานี้ด้วย main thread safety เป็น missing piece สุดท้ายสำหรับ My Crypto Coins app

Coroutines เป็น Kotlin feature แปลง async callbacks สำหรับ long-running tasks (database/network access) เป็น sequential code ด้วย coroutines เขียน asynchronous code ที่แบบเดิมเขียนด้วย Callback pattern ด้วย synchronous style Return value ของ function จะให้ result ของ asynchronous call โค้ด sequential ทั่วไปอ่านง่ายกว่า และใช้ language features เช่น exceptions ได้

เราจะใช้ coroutines ทุกที่ที่ต้องรอ result จาก long-running task แล้วค่อยดำเนินการต่อ มาดู exact implementation สำหรับ ViewModel ที่จะ retry ดึง latest data จาก server สำหรับ cryptocurrencies บน main screen

เพิ่ม coroutines ในโปรเจกต์:

// Coroutines support libraries for Kotlin // Dependencies for coroutines implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines" // Dependency สำหรับ special UI context ส่งให้ coroutine builders ที่ใช้ main thread dispatcher dispatch events บน main thread implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines"

สร้าง abstract class เป็น base class สำหรับ ViewModel ใดๆ ที่ต้อง common functionality เช่น coroutines:

abstract class BaseViewModel : ViewModel() { // ใน Kotlin coroutines ทั้งหมดรันใน CoroutineScope // Scope ควบคุม lifetime ของ coroutines ผ่าน job private val viewModelJob = Job() // เนื่องจาก uiScope มี default dispatcher Dispatchers.Main coroutine นี้จะ launch ใน main thread val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) // onCleared เรียกเมื่อ ViewModel ไม่ได้ใช้แล้วและจะ destroyed // ปกติเกิดเมื่อ user navigate away from Activity/Fragment ที่ใช้ ViewModel override fun onCleared() { super.onCleared() // Cancel job ของ scope จะ cancel coroutines ทั้งหมดที่เริ่มใน scope // สำคัญ cancel coroutines ที่ไม่ต้องแล้ว เพื่อกัน unnecessary work และ memory leaks viewModelJob.cancel() } }

ที่นี่สร้าง coroutine scope เฉพาะควบคุม lifetime ผ่าน job เห็นได้ scope ให้ specify default dispatcher ควบคุม thread ไหน run coroutine เมื่อ ViewModel ไม่ได้ใช้แล้ว cancel viewModelJob และ coroutines ทุกตัวที่เริ่มโดย uiScope จะถูก cancel ด้วย

สุดท้าย implement retry functionality:

/** * ViewModel class designed to store และ manage UI-related data in lifecycle conscious way * ViewModel allows data survive configuration changes เช่น screen rotations */ // ViewModel ต้องการ CryptocurrencyRepository เพิ่ม @Inject ใน constructor class MainViewModel @Inject constructor(val context: Context, val cryptocurrencyRepository: CryptocurrencyRepository) : BaseViewModel() { ... val mediatorLiveDataMyCryptocurrencyResourceList = MediatorLiveData<Resource<List<MyCryptocurrency>>>() private var liveDataMyCryptocurrencyResourceList: LiveData<Resource<List<MyCryptocurrency>>> private val liveDataMyCryptocurrencyList: LiveData<List<MyCryptocurrency>> ... // Helper variable เพิ่มเติม deal correctly with currency spinner และ preference // เก็บใน viewmodel ไม่ให้ lost เพราะ fragment/activity recreation var newSelectedFiatCurrencyCode: String? = null // Helper variable เก็บ state of swipe refresh layout var isSwipeRefreshing: Boolean = false init { ... // Set resource value สำหรับ list ของ cryptocurrencies ที่ user มี liveDataMyCryptocurrencyResourceList = cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList(cryptocurrencyRepository.getCurrentFiatCurrencyCode()) // Declare additional variable เพื่อ reload data on demand mediatorLiveDataMyCryptocurrencyResourceList.addSource(liveDataMyCryptocurrencyResourceList) { mediatorLiveDataMyCryptocurrencyResourceList.value = it } ... } ... /** * On retry ต้อง run sequential code ก่อนต้องได้ owned crypto coins ids จาก * local database รอ response และหลังจากนั้นใช้ ids นี้ call ด้วย * retrofit ได้ updated owned crypto values ทำได้ด้วย Kotlin Coroutines */ fun retry(newFiatCurrencyCode: String? = null) { // เก็บ new selected currency เป็น additional variable หรือ reset // ภายหลังถ้า call server unsuccessful จะ reuse สำหรับ retry functionality newSelectedFiatCurrencyCode = newFiatCurrencyCode // Launch coroutine ใน uiScope uiScope.launch { // Call server หลัง delay สำหรับ UX ดีขึ้น updateMyCryptocurrencyList(newFiatCurrencyCode, SERVER_CALL_DELAY_MILLISECONDS) } } // Refresh data จาก local database fun refreshMyCryptocurrencyResourceList() { refreshMyCryptocurrencyResourceList(cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList(cryptocurrencyRepository.getCurrentFiatCurrencyCode())) } // Implement manual refresh โดยไม่ modify existing LiveData logic private fun refreshMyCryptocurrencyResourceList(liveData: LiveData<Resource<List<MyCryptocurrency>>>) { 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 // function withContext เป็น suspend function withContext ทันที shift execution ของ block ไป thread อื่นใน block และกลับมาตอน completes IO dispatcher เหมาะสำหรับ execute network requests ใน IO thread val myCryptocurrencyIds = withContext(Dispatchers.IO) { // Suspend จนกว่า getMyCryptocurrencyIds() จะคืน result cryptocurrencyRepository.getMyCryptocurrencyIds() } // กลับมาที่ main worker thread ทันทีที่ myCryptocurrencyIds มี result // และ main looper available coroutine resume บน main thread และ // [getMyCryptocurrencyLiveDataResourceList] ถูกเรียก // รอ background operations complete โดยไม่ block original thread refreshMyCryptocurrencyResourceList( cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList (fiatCurrencyCode, true, myCryptocurrencyIds, callDelay)) } ... }

ที่นี่เรียก function marked ด้วย keyword suspend สำหรับ coroutines หมายถึง function suspend execution จนกว่า result พร้อม แล้ว resume ที่หยุดไว้พร้อม result ขณะ suspended รอ result มัน unblock thread ที่รันอยู่

ใน suspend function หนึ่ง เรียก suspend function อื่นได้ เห็นได้ว่า ทำโดยเรียก withContext suspend function ใหม่ executed on different thread

ความคิดของโค้ดทั้งหมดคือ combine multiple calls สร้าง sequential code สวยงาม ก่อน request ids ของ cryptocurrencies ที่เราครองจาก local database รอ response พอได้แล้ว ใช้ response ids call ใหม่กับ Retrofit ดึง updated cryptocurrency values นั่นคือ retry functionality

เราทำได้! Final thoughts, repository, app & presentation

ขอแสดงความยินดี หากคุณอ่านจนจบ ทุกจุดสำคัญสำหรับสร้างแอปนี้ครอบคลุมแล้ว มี stuff ใหม่เยอะใน part นี้ และมากไม่ได้ cover ในบทความ แตผม comment โค้ดทุกที่ดีแล้ว คุณไม่ควรหลงทาง เช็ค final code part 5 บน GitHub:

View Source On GitHub.

Challenge ใหญ่สุดสำหรับผมส่วนตัว ไม่ใช่เรียนเทคโนโลยีใหม่ ไม่ใช่พัฒนาแอป แต่เขียนบทความทั้งหมดนี้ จริงๆ ผมสุขใจมากที่ complete challenge นี้ การเรียนรู้และพัฒนาง่ายกว่าสอนคนอื่น แต่ที่สอนคุณเข้าใจ topic ดีกว่า คำแนะนำหากหาวิธีเรียนรู้ใหม่ๆ ที่ดีที่สุด คือเริ่มสร้างสิ่ง gìสักอย่างทันที สัญญาว่าคุณจะเรียนรู้เยอะและเร็ว

บทความทั้งหมด based on version 1.0.0 ของ "Kriptofolio" (ก่อนหน้า "My Crypto Coins") app ดาวน์โหลดเป็น APK แยกได้ที่นี่ แต่ผมจะยินดีมากถ้าติดตั้งและ rate latest app version จาก store โดยตรง:

Get It On Google Play

เยี่ยมชม presentation website เรียบง่ายที่ผมทำสำหรับโปรเจกต์นี้:

Kriptofolio.app

เชี่ยวชาญ RESTful Services ใน Android: Retrofit, OkHttp, Gson, Glide & Coroutines

Ačiū! Thanks for reading! ผมตีพิมพ์โพสต์นี้ต้นฉบับสำหรับ personal blog www.baruckis.com วันที่ 11 พฤษภาคม 2019