Kriptofolio app series — Phần 5
Ngày nay hầu như mọi Android app đều kết nối internet để lấy/gửi dữ liệu. Bạn chắc chắn nên học cách xử lý RESTful Web Services, vì triển khai đúng là kiến thức cốt lõi tạo app hiện đại.
Phần này khá phức tạp. Chúng ta sẽ kết hợp nhiều thư viện cùng lúc để có kết quả hoạt động. Tôi không nói về cách native Android xử lý internet request, vì thực tế không ai dùng. Mọi app tốt không tái tạo bánh xe mà dùng thư viện third-party phổ biến giải quyết vấn đề chung. Quá phức tạp để tái tạo chức năng những thư viện well-made này cung cấp.
Nội dung series
- Introduction: Roadmap xây dựng modern Android app 2018–2019
- Phần 1: Giới thiệu nguyên tắc SOLID
- Phần 2: Bắt đầu xây dựng Android app: tạo Mockups, UI, XML layouts
- Phần 3: Tất cả về Architecture: khám phá pattern kiến trúc và cách dùng trong app
- Phần 4: Triển khai Dependency Injection với Dagger 2
- Phần 5: Xử lý RESTful Web Services dùng Retrofit, OkHttp, Gson, Glide và Coroutines (bạn đang ở đây)
Retrofit, OkHttp và Gson là gì?
Retrofit là REST Client cho Java và Android. Thư viện này, theo tôi, quan trọng nhất để học, vì nó làm công việc chính. Nó làm cho việc retrieve và upload JSON (hoặc structured data khác) qua REST webservice tương đối dễ dàng.
Trong Retrofit bạn cấu hình converter dùng cho data serialization. Thường để serialize/deserialize object từ/đến JSON dùng thư viện Java open-source — Gson. Cũng có thể thêm custom converter cho Retrofit xử lý XML hoặc protocol khác.
Để làm HTTP request, Retrofit dùng thư viện OkHttp. OkHttp là pure HTTP/SPDY client chịu trách nhiệm low-level network operation, caching, thao tác request/response. Ngược lại, Retrofit là high-level REST abstraction xây trên OkHttp. Retrofit gắn chặt với OkHttp và dùng nó nhiều.
Biết mọi thứ liên quan chặt chẽ, chúng ta sẽ dùng cả 3 thư viện. Mục tiêu đầu: lấy danh sách cryptocurrency dùng Retrofit từ Internet. Dùng interceptor class OkHttp đặc biệt cho authentication CoinMarketCap API khi gọi server. Nhận JSON data result và convert dùng Gson.
Quick setup cho Retrofit 2 chỉ để thử nhanh
Khi học cái mới, tôi thích thử thực tế sớm. Chúng ta áp dụng cách tương tự với Retrofit 2 để bạn hiểu nhanh hơn. Đừng lo code quality hay principle hay optimization — chỉ viết code chạy Retrofit 2 trong project và thảo luận nó làm gì.
Làm theo steps setup Retrofit 2 trên project My Crypto Coins app:
Trước hết, cấp quyền INTERNET cho app
Chúng ta sẽ thực thi HTTP request trên server truy cập qua Internet. Cấp permission bằng thêm dòng vào Manifest:
<manifest xmlns:android="https://schemas.android.com/apk/res/android" package="com.baruckis.mycryptocoins"> <uses-permission android:name="android.permission.INTERNET" /> ... </manifest>Sau đó thêm library dependencies
Tìm Retrofit version mới nhất. Cũng biết Retrofit không ship với JSON converter tích hợp. Vì nhận response JSON, cần include converter manual trong dependencies. Dùng Google Gson mới nhất. Thêm vào 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"Như comment, OkHttp dependency đã ship với Retrofit 2 dependency. Versions là file gradle riêng cho tiện:
def versions = [:] versions.retrofit = "2.4.0" ext.versions = versionsTiếp theo setup Retrofit interface
Interface khai báo request và type. Ở đây define API client side.
/** * REST API access points. */ interface ApiService { // @GET annotation báo retrofit request là GET type. // String value báo retrofit path request là // baseUrl + v1/cryptocurrency/listings/latest + query parameter. @GET("v1/cryptocurrency/listings/latest") // @Query annotation dùng định nghĩa query parameter. Cuối cùng request url // trông như https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?convert=EUR. fun getAllCryptocurrencies(@Query("convert") currency: String): Call<CryptocurrenciesLatest> // Return type là Call với type CryptocurrenciesLatest. }Và setup data class
Data class là POJO đại diện response của API call chúng ta sẽ làm.
/** * Data class xử lý response từ 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, // Annotation cho model property cho phép pass serialized/deserialized // name as string. Hữu ích nếu không muốn model class và JSON trùng tên. @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( // Option thêm khi deserialize: specify value hoặc alternative // values. Gson check JSON cho tất cả tên chỉ định và thử map vào 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 ) }Tạo interceptor class đặc biệt cho authentication khi gọi server
Trường hợp này dành cho API yêu cầu authentication để có response thành công. Interceptor là cách mạnh mẽ customize request. Chúng ta sẽ intercept request thực tế và thêm header riêng, validate call với API Key từ CoinMarketCap Professional API Developer Portal. Để lấy key, đăng ký ở đó.
/** * Interceptor dùng intercept request thực tế và * cung cấp API Key trong REST API calls qua custom header. */ class AuthenticationInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val newRequest = chain.request().newBuilder() // TODO: Dùng API Key từ CoinMarketCap Professional API Developer Portal. .addHeader("X-CMC_PRO_API_KEY", "CMC_PRO_API_KEY") .build() return chain.proceed(newRequest) } }Cuối cùng, thêm code này vào Activity để thấy Retrofit hoạt động
Tôi muốn bạn thực hành sớm, nên gộp tất cả ở một chỗ. Đây không phải cách đúng, nhưng nhanh nhất để thấy kết quả visual.
class AddSearchActivity : AppCompatActivity(), Injectable { private lateinit var listView: ListView private lateinit var listAdapter: AddSearchListAdapter ... override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... // Sau sẽ setup Retrofit đúng cách, nhưng tạm thời gộp cho quick start. setupRetrofitTemporarily() } ... private fun setupRetrofitTemporarily() { // Cần custom OkHttp client vì cần custom call interceptor // để authenticate request. val builder = OkHttpClient.Builder() // Thêm interceptor vào OkHttpClient. // Sẽ thêm authentication header cho mọi call. builder.interceptors().add(AuthenticationInterceptor()) val client = builder.build() val api = Retrofit.Builder() // Tạo retrofit builder. .baseUrl("https://sandbox-api.coinmarketcap.com/") // Base url API phải kết thúc bằng slash. .addConverterFactory(GsonConverterFactory.create()) // Dùng GSON converter cho JSON to POJO mapping. .client(client) // Set custom OkHttp client vừa tạo. .build().create(ApiService::class.java) // Tạo API dùng interface đã define. val adapterData: MutableList<Cryptocurrency> = ArrayList<Cryptocurrency>() val currentFiatCurrencyCode = "EUR" // Gọi network request async lấy all latest cryptocurrencies từ server. // Query parameter truyền "EUR" vì muốn giá euro. val call = api.getAllCryptocurrencies("EUR") val result = call.enqueue(object : Callback<CryptocurrenciesLatest> { // Luôn nhận response даже nếu server lỗi. override fun onFailure(call: Call<CryptocurrenciesLatest>, t: Throwable) { Snackbar.make(findViewById(android.R.id.content), // Throwable cho tìm error nếu call failed. "Call failed! " + t.localizedMessage, Snackbar.LENGTH_INDEFINITE).show() } override fun onResponse(call: Call<CryptocurrenciesLatest>, response: Response<CryptocurrenciesLatest>) { // Check response successful, nghĩa là request received, understood, accepted, code [200..300). if (response.isSuccessful) { // Nếu OK, báo cho user. Toast.makeText(this@AddSearchActivity, "Call OK.", Toast.LENGTH_LONG).show(); // Map server response data nhanh sang 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) } // Nếu response unsuccessful, định nghĩa bằng HTTP error code đặc biệt, show cho user. else Snackbar.make(findViewById(android.R.id.content), "Call error with HTTP status code " + response.code() + "!", Snackbar.LENGTH_INDEFINITE).show() } }) } ... }Bạn có thể explore code tại đây. Nhớ đây chỉ là implementation đơn giản ban đầu để hiểu ý tưởng.
Final correct setup cho Retrofit 2 với OkHttp 3 và Gson
Ok sau thử nhanh, giờ đưa Retrofit implementation lên level. Chúng ta đã lấy data thành công nhưng chưa đúng. Thiếu state loading, error, success. Code mixed không separation of concerns. Sai lầm thường gặp: viết tất cả code trong Activity/Fragment. Activity class là UI based nên chỉ chứa logic handle UI và OS interaction.
Thực tế, sau quick setup, tôi làm nhiều thay đổi. Không cần put tất cả code changed vào bài. Thay vào đó bạn nên browse final Part 5 code repo tại đây. Tôi comment rất kỹ và code rõ ràng. Nhưng tôi nói về những việc quan trọng tôi làm và tại sao.
Bước đầu cải thiện: dùng Dependency Injection. Nhớ phần trước đã implement Dagger 2 đúng. Tôi dùng nó cho Retrofit setup.
/** * AppModule cung cấp app-wide dependencies cho phần app. * Khởi tạo object dùng xuyên app: Room database, Retrofit, Shared Preference, etc. */ @Module(includes = [ViewModelsModule::class]) class AppModule() { ... @Provides @Singleton fun provideHttpClient(): OkHttpClient { // Cần custom OkHttp client vì cần custom call interceptor // để authenticate request. val builder = OkHttpClient.Builder() // Thêm interceptor vào OkHttpClient. // Thêm authentication header cho mọi call. builder.interceptors().add(AuthenticationInterceptor()) // Cấu hình client không retry khi gặp connectivity problem. builder.retryOnConnectionFailure(false) // Log request/response. // Add logging làm interceptor cuối, vì cũng log info bạn thêm/manipulate // với interceptor trước. builder.interceptors().add(HttpLoggingInterceptor().apply { // Production env để enhance performance sẽ skip logging. Chỉ show log 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() // Tạo retrofit builder. .baseUrl(API_SERVICE_BASE_URL) // Base url API phải kết thúc slash. .addConverterFactory(GsonConverterFactory.create()) // GSON converter JSON to POJO. .addCallAdapterFactory(LiveDataCallAdapterFactory()) .client(httpClient) // Set custom OkHttp client. .build().create(ApiService::class.java) // Tạo API dùng interface. } ... }Bây giờ Retrofit tách khỏi Activity class như đúng. Chỉ khởi tạo một lần dùng app-wide.
Như bạn thấy tạo Retrofit builder instance, thêm special Retrofit calls adapter dùng addCallAdapterFactory. Mặc định Retrofit trả về Call<T>, nhưng project cần LiveData<T>. Để làm vậy cần thêm LiveDataCallAdapter qua LiveDataCallAdapterFactory.
/** * Retrofit adapter chuyển Call thành LiveData của 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) } }Giờ chúng ta nhận LiveData<T> thay vì Call<T> làm return type từ Retrofit service method định nghĩa trong ApiService interface.
Bước quan trọng khác: dùng Repository pattern. Tôi nói Part 3. Xem MVVM architecture schema bài đó nhớ vị trí.

Như hình, Repository là layer riêng cho data. Là single source of contact lấy/gửi data. Dùng Repository tuân thủ separation of concerns. Có thể có data source khác (persistent SQLite và web service), nhưng Repository luôn single source of truth cho toàn app data.
Thay vì communicate trực tiếp Retrofit, dùng Repository. Mỗi entity type, một Repository riêng.
/** * Class quản lý 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 lưu selected fiat currency code trong app lifecycle. // Cần cho main screen currency spinner. Set giống shared preferences. var selectedFiatCurrencyCode: String = getCurrentFiatCurrencyCode() ... // Resource wrapping LiveData hữu ích update UI dựa trên state. fun getAllCryptocurrencyLiveDataResourceList(fiatCurrencyCode: String, shouldFetch: Boolean = false, callDelay: Long = 0): LiveData<Resource<List<Cryptocurrency>>> { return object : NetworkBoundResource<List<Cryptocurrency>, CoinMarketCap<List<CryptocurrencyLatest>>>(appExecutors) { // Lưu data fetched từ 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 chỉ fetch từ web hay không, true = fetch. override fun shouldFetch(data: List<Cryptocurrency>?): Boolean { return data == null || shouldFetch } override fun fetchDelayMillis(): Long { return callDelay } // Logic lấy data từ Room database. override fun loadFromDb(): LiveData<List<Cryptocurrency>> { return Transformations.switchMap(cryptocurrencyDao.getAllCryptocurrencyLiveDataList()) { data -> if (data.isEmpty()) { AbsentLiveData.create() } else { cryptocurrencyDao.getAllCryptocurrencyLiveDataList() } } } // Logic lấy data từ web-service dùng 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> } }Như thấy trong code CryptocurrencyRepository, tôi dùng abstract class NetworkBoundResource. Nó là gì và tại sao cần?
NetworkBoundResource là helper class nhỏ nhưng quan trọng cho phép maintain synchronization giữa local database và web service. Mục tiêu: xây modern app hoạt động mượt offline. Cũng nhờ class này present các network state như error, loading cho user visual.
NetworkBoundResource bắt đầu observe database cho resource. Khi entry load từ database lần đầu, check kết quả đủ tốt dispatch hay re-fetch từ network. Cả hai có thể cùng lúc, vì bạn muốn show cached data trong khi update từ network.
Nếu network call thành công, lưu response vào database và re-initialize stream. Nếu network request fail, NetworkBoundResource dispatch failure trực tiếp.
/** * Generic class cung cấp resource backed bởi sqlite database và network. * * * Đọc thêm tại [Architecture * Guide](https://developer.android.com/arch). * @param <ResultType> - Type cho Resource data. * @param <RequestType> - Type cho API response. </RequestType></ResultType> */ // Định nghĩa hai type parameter ResultType và RequestType, // vì data type từ API có thể không match data type local. abstract class NetworkBoundResource<ResultType, RequestType> @MainThread constructor(private val appExecutors: AppExecutors) { // Final result LiveData. private val result = MediatorLiveData<Resource<ResultType>>() init { // Gửi loading state đến 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 từ network, persist vào DB, gửi về UI. private fun fetchFromNetwork(dbSource: LiveData<ResultType>) { val apiResponse = createCall() // Re-attach dbSource như source mới, dispatch latest value nhanh. result.addSource(dbSource) { newData -> setValue(Resource.loading(newData)) } // Tạo 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 { // Yêu cầu live data mới đặc biệt, // không thì nhận ngay last cached value, // có thể chưa update với kết quả mới từ network. result.addSource(loadFromDb()) { newData -> setValue(Resource.successNetwork(newData)) } } } } is ApiEmptyResponse -> { appExecutors.mainThread().execute { // reload từ 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 trước call nếu cần. val delay = fetchDelayMillis() if (delay > 0) { Handler().postDelayed({ fetch() }, delay) } else fetch() } // Gọi khi fetch fail. Child class có thể reset component như rate limiter. protected open fun onFetchFailed() {} // Trả LiveData object represent resource implement ở base class. fun asLiveData() = result as LiveData<Resource<ResultType>> @WorkerThread protected open fun processResponse(response: ApiSuccessResponse<RequestType>) = response.body // Lưu kết quả API response vào database. @WorkerThread protected abstract fun saveCallResult(item: RequestType) // Called với data trong database quyết định fetch updated data từ network. @MainThread protected abstract fun shouldFetch(data: ResultType?): Boolean // Gọi server sau delay cho UX tốt hơn. protected open fun fetchDelayMillis(): Long = 0 // Lấy cached data từ database. @MainThread protected abstract fun loadFromDb(): LiveData<ResultType> // Tạo API call. @MainThread protected abstract fun createCall(): LiveData<ApiResponse<RequestType>> }Dưới mặt nạ, NetworkBoundResource dùng MediatorLiveData và khả năng observe nhiều LiveData source cùng lúc. Ở đây hai LiveData source: database và network call response. Cả hai wrap vào một MediatorLiveData exposed bởi NetworkBoundResource.

Hãy xem chi tiết NetworkBoundResource hoạt động trong app. User launch app, click floating action button góc phải dưới. App launch add crypto coins screen. Phân tích usage trong screen này.
Nếu app fresh install, first launch, không có data trong local database. Vì không data show, loading progress bar UI show. App gọi request server qua web service lấy all cryptocurrencies.
Nếu response unsuccessful, error message UI show với retry button. Khi request thành công, response data lưu vào SQLite local database.
Quay lại screen lần sau, app load data từ database thay vì gọi internet. User có thể yêu cầu update mới bằng pull-to-refresh. Old data show trong khi network call. Tất cả nhờ NetworkBoundResource.
Class khác dùng trong Repository và LiveDataCallAdapter nơi "magic" xảy ra là ApiResponse. Thực tế ApiResponse chỉ simple common wrapper quanh Retrofit2.Response convert mỗi response thành instance LiveData.
/** * Common class dùng cho API responses. ApiResponse là wrapper đơn giản quanh Retrofit2.Call * class convert responses thành instances của LiveData. * @param <CoinMarketCapType> type của response object </T> */ @Suppress("unused") // T dùng trong 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 thành 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 riêng cho HTTP 204 response để 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>()Trong wrapper class, nếu response error, dùng Gson convert error thành JSON object. Nếu response successful, Gson converter cho JSON to POJO mapping được dùng. Chúng ta đã add khi tạo retrofit builder với GsonConverterFactory trong Dagger AppModule function provideApiService.
Glide cho image loading
Glide là gì? Từ docs:
Glide là framework quản lý media và image loading nhanh, hiệu quả, open source cho Android wrap media decoding, memory/disk caching, resource pooling vào interface đơn giản, dễ dùng.
Focus chính Glide là làm cho scrolling bất kỳ list hình ảnh nào smooth và fast nhất, nhưng cũng effective cho hầu hết trường hợp cần fetch, resize, display remote image.
Nghe như thư viện phức tạp cung cấp nhiều feature hữu ích không muốn tự develop. Trong My Crypto Coins app, có vài list screen cần show nhiều cryptocurrency logos — hình từ internet cùng lúc — vẫn đảm bảo smooth scrolling. Thư viện này fit perfectly. Cũng rất popular trong Android dev.
Steps setup Glide trên project My Crypto Coins app:
Khai báo dependencies
Lấy Glide version mới nhất. Lại versions là file riêng versions.gradle cho 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"Vì muốn dùng networking library OkHttp cho mọi network operation, cần include specific Glide integration cho nó thay vì default. Vì Glide sẽ network request load image qua internet, cần permission INTERNET trong AndroidManifest.xml — nhưng đã làm ở Retrofit setup.
Tạo AppGlideModule
Glide v4, chúng ta dùng, cung cấp generated API cho Application. Dùng annotation processor generate API cho phép application extend Glide API và include component từ integration library. Để app truy cập generated Glide API, cần include annotated AppGlideModule implementation. Chỉ có một implementation generated API và một AppGlideModule per application.
Tạo class extend AppGlideModule ở đâu đó trong project:
/** * Glide v4 dùng annotation processor generate API cho phép app truy cập tất cả * options trong RequestBuilder, RequestOptions và integration libraries trong một * fluent API. * * Generated API phục vụ hai mục đích: * Integration libraries extend Glide API với custom options. * Applications extend Glide API bằng thêm method bundle commonly used options. * * Dù cả hai có thể làm thủ công bằng custom subclasses RequestOptions, nhưng thử thách và API kém fluent. */ @GlideModule class AppGlideModule : AppGlideModule()Dù app không thay đổi settings hoặc implement method trong AppGlideModule, vẫn cần implementation để dùng Glide. Không bắt buộc implement method nào cho API generated. Có thể để class trống miễn extend AppGlideModule và annotate @GlideModule.
Sử dụng Glide-generated API
Khi dùng AppGlideModule, app dùng API bằng cách bắt đầu all loads với GlideApp.with(). Code dưới đây show cách tôi dùng Glide load và show cryptocurrency logos trong 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 ... // Tạo Uri image cần load. Mỗi image unique name là 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 từ AppGlideModule. GlideApp // Cần cung cấp context để call. .with(itemBinding.root) // Chỉ định image load bằng Uri. .load(imageUri) // Cách combine và execute multiple transformations. // WhiteBackground là custom transformation tự implement. // CircleCrop là default transformation Glide ship. .transform(MultiTransformation(WhiteBackground(), CircleCrop())) // Target ImageView image hiển thị. .into(itemBinding.itemImageIcon.imageview_front) ... return itemBinding.root } ... }Như thấy, dùng Glide chỉ vài dòng code và để nó làm việc nặng nhọc. Rất straightforward.
Kotlin Coroutines
Xây app này, sẽ gặp situation chạy time-consuming task như write/read database, fetch data từ network, v.v. Những task common này mất thời gian hơn main thread Android framework cho phép.
Main thread là single thread handle tất cả UI update. Developer required không block để tránh app freeze hoặc crash ANR. Kotlin coroutines giải quyết bằng main thread safety. Là missing piece cuối cho My Crypto Coins app.
Coroutines là feature Kotlin convert async callbacks cho long-running task (database, network access) thành sequential code. Với coroutines, viết asynchronous code truyền thống dùng Callback pattern, dùng synchronous style. Return value của function cung cấp kết quả async call. Code sequential thường dễ đọc, thậm chí dùng language feature như exceptions.
Chúng ta dùng coroutines mọi nơi cần wait kết quả từ long-running task rồi tiếp tục. Xem implementation exact cho ViewModel retry lấy latest data từ server cho cryptocurrencies trên main screen.
Thêm coroutines vào project:
// Coroutines support libraries cho Kotlin. // Dependencies cho coroutines. implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$versions.coroutines" // Dependency cho special UI context pass cho coroutine builders dùng // main thread dispatcher dispatch event trên main thread. implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$versions.coroutines"Sau đó tạo abstract class base cho ViewModel cần common functionality như coroutines:
abstract class BaseViewModel : ViewModel() { // Trong Kotlin, tất cả coroutines chạy trong CoroutineScope. // Scope kiểm soát lifetime coroutines qua job. private val viewModelJob = Job() // Vì uiScope có default dispatcher Dispatchers.Main, coroutine này launch // trên main thread. val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob) // onCleared gọi khi ViewModel không dùng nữa và sẽ destroyed. // Thường xảy ra khi user navigate away từ Activity/Fragment dùng ViewModel. override fun onCleared() { super.onCleared() // Cancel job của scope, cancel tất cả coroutines bắt đầu trong scope. // Quan trọng cancel coroutines không còn cần để tránh unnecessary work và memory leaks. viewModelJob.cancel() } }Ở đây tạo specific coroutine scope kiểm soát lifetime qua job. Như thấy, scope cho specify default dispatcher kiểm soát thread chạy coroutine. Khi ViewModel không dùng, cancel viewModelJob và mọi coroutine start bởi uiScope bị cancel.
Cuối cùng, implement retry functionality:
/** * ViewModel class designed store và manage UI-related data lifecycle conscious. * ViewModel cho phép data survive configuration changes như screen rotation. */ // ViewModel yêu cầu CryptocurrencyRepository nên add @Inject vào 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 thêm deal correctly với currency spinner và preference. // Giữ trong viewmodel không lost do fragment/activity recreation. var newSelectedFiatCurrencyCode: String? = null // Helper variable lưu state swipe refresh layout. var isSwipeRefreshing: Boolean = false init { ... // Set resource value cho list cryptocurrencies user sở hữu. liveDataMyCryptocurrencyResourceList = cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList(cryptocurrencyRepository.getCurrentFiatCurrencyCode()) // Khai báo variable thêm để reload data on demand. mediatorLiveDataMyCryptocurrencyResourceList.addSource(liveDataMyCryptocurrencyResourceList) { mediatorLiveDataMyCryptocurrencyResourceList.value = it } ... } ... /** * On retry cần chạy sequential code. First lấy owned crypto coins ids từ * local database, wait response và chỉ sau dùng ids gọi retrofit lấy * updated owned crypto values. Có thể làm bằng Kotlin Coroutines. */ fun retry(newFiatCurrencyCode: String? = null) { // Lưu new selected currency làm variable thêm hoặc reset. // Sau nếu call server unsuccessful sẽ reuse cho retry. newSelectedFiatCurrencyCode = newFiatCurrencyCode // Launch coroutine trong uiScope. uiScope.launch { // Gọi server sau delay cho UX tốt hơn. updateMyCryptocurrencyList(newFiatCurrencyCode, SERVER_CALL_DELAY_MILLISECONDS) } } // Refresh data từ local database. fun refreshMyCryptocurrencyResourceList() { refreshMyCryptocurrencyResourceList(cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList(cryptocurrencyRepository.getCurrentFiatCurrencyCode())) } // Manual refresh không 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 // withContext là suspend function. withContext ngay lập tức shift execution block vào thread khác trong block, và back khi complete. IO dispatcher phù hợp execute network requests IO thread. val myCryptocurrencyIds = withContext(Dispatchers.IO) { // Suspend cho đến khi getMyCryptocurrencyIds() trả kết quả. cryptocurrencyRepository.getMyCryptocurrencyIds() } // Quay về main worker thread. Ngay khi myCryptocurrencyIds có result // và main looper available, coroutine resume trên main thread, // [getMyCryptocurrencyLiveDataResourceList] được gọi. // Wait background operations complete, không block original thread. refreshMyCryptocurrencyResourceList( cryptocurrencyRepository.getMyCryptocurrencyLiveDataResourceList (fiatCurrencyCode, true, myCryptocurrencyIds, callDelay)) } ... }Ở đây gọi function marked với keyword Kotlin suspend cho coroutines. Nghĩa là function suspend execution cho đến khi result ready, rồi resume tại chỗ với result. Trong lúc suspended chờ result, nó unblock thread đang chạy.
Cũng, trong một suspend function có thể gọi suspend function khác. Như thấy, gọi new suspend function marked withContext execute trên thread khác.
Ý tưởng code này: combine multiple calls thành sequential code đẹp. First request lấy ids cryptocurrencies owned từ local database, wait response. Chỉ sau khi có response dùng ids đó gọi Retrofit mới lấy updated cryptocurrency values. Đó là retry functionality.
Chúng ta làm được! Final thoughts, repository, app & presentation
Chúc mừng, tôi vui nếu bạn đọc đến cuối. Tất cả điểm quan trọng tạo app này đã covered. Phần này có nhiều stuff mới và nhiều không covered bài viết, nhưng tôi comment code everywhere rất kỹ nên bạn không bị lạc. Check final code Part 5 tại GitHub:
View Source On GitHub.
Thách thức lớn nhất tôi cá nhân không phải học tech mới, không phải develop app, mà viết tất cả bài viết này. Thực sự hài lòng tôi complete challenge. Học và develop dễ so với dạy người khác, nhưng đó là nơi hiểu topic sâu hơn. Lời khuyên nếu tìm best way learn new things: bắt đầu tạo cái gì đó ngay. Tôi hứa bạn học nhiều và nhanh.
Tất cả bài viết dựa trên version 1.0.0 của “Kriptofolio” (trước “My Crypto Coins”) app download APK riêng tại đây. Nhưng tôi rất vui nếu bạn cài và rate latest app version từ store:
Get It On Google Play
Cũng hãy visit presentation website đơn giản tôi làm cho project:
Kriptofolio.app

Ačiū! Thanks for reading! Tôi xuất bản bài này cho blog cá nhân www.baruckis.com ngày 11 tháng 5, 2019.